feat(core): implement feature lifecycle management (Alpha, Beta, GA)

Introduce FeatureGate system with FeatureStage enum, FeatureSpec
metadata, and DefaultFeatureGate singleton. Features progress through
Alpha (off), Beta (on), GA (locked on), and Deprecated stages.

Integrate FeatureGate into Config with isFeatureEnabled() and legacy
experimental flag mapping for backwards compatibility.

Fixes #21324
This commit is contained in:
Jerop Kipruto
2026-03-05 21:18:30 -05:00
parent 6aa6630137
commit 2ea491d73f
5 changed files with 891 additions and 30 deletions

View File

@@ -2391,6 +2391,97 @@ describe('Config setExperiments logging', () => {
});
});
describe('FeatureGate Integration', () => {
const baseParams: ConfigParameters = {
cwd: '/tmp',
targetDir: '/path/to/target',
debugMode: false,
sessionId: 'test-session-id',
model: 'gemini-pro',
usageStatisticsEnabled: false,
};
it('should initialize FeatureGate with defaults', () => {
const config = new Config(baseParams);
expect(config.isFeatureEnabled('plan')).toBe(false);
});
it('should respect "features" setting', () => {
const params = {
...baseParams,
features: { plan: true },
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should respect "featureGates" CLI flag', () => {
const params = {
...baseParams,
featureGates: 'plan=true',
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should respect "featureGates" CLI flag overriding settings', () => {
const params = {
...baseParams,
features: { plan: false },
featureGates: 'plan=true',
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should respect legacy "experimental" settings if feature is not explicitly set', () => {
const params = {
...baseParams,
plan: true,
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should prioritize "features" over legacy settings', () => {
const params = {
...baseParams,
plan: true,
features: { plan: false },
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(false);
});
it('should respect stage-based toggles from settings', () => {
const params = {
...baseParams,
features: { allAlpha: true },
};
const config = new Config(params);
expect(config.isFeatureEnabled('plan')).toBe(true);
});
it('should resolve specific feature accessors using FeatureGate', () => {
const config = new Config({
...baseParams,
features: {
jitContext: true,
toolOutputMasking: false,
extensionManagement: true,
plan: true,
enableAgents: true,
},
});
expect(config.isJitContextEnabled()).toBe(true);
expect(config.getToolOutputMaskingEnabled()).toBe(false);
expect(config.getExtensionManagement()).toBe(true);
expect(config.isPlanEnabled()).toBe(true);
expect(config.isAgentsEnabled()).toBe(true);
});
});
describe('Availability Service Integration', () => {
const baseModel = 'test-model';
const baseParams: ConfigParameters = {

View File

@@ -114,6 +114,7 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
import { Storage } from './storage.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import { FileExclusions } from '../utils/ignorePatterns.js';
import { DefaultFeatureGate, type FeatureGate } from './features.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { EventEmitter } from 'node:events';
import { PolicyEngine } from '../policy/policy-engine.js';
@@ -584,6 +585,8 @@ export interface ConfigParameters {
tracker?: boolean;
planSettings?: PlanSettings;
modelSteering?: boolean;
features?: Record<string, boolean>;
featureGates?: string;
onModelChange?: (model: string) => void;
mcpEnabled?: boolean;
extensionsEnabled?: boolean;
@@ -723,14 +726,12 @@ export class Config implements McpContext {
readonly interactive: boolean;
private readonly ptyInfo: string;
private readonly trustedFolder: boolean | undefined;
private readonly directWebFetch: boolean;
private readonly useRipgrep: boolean;
private readonly enableInteractiveShell: boolean;
private readonly skipNextSpeakerCheck: boolean;
private readonly useBackgroundColor: boolean;
private readonly useAlternateBuffer: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly truncateToolOutputThreshold: number;
private compressionTruncationCounter = 0;
private initialized = false;
@@ -784,19 +785,16 @@ export class Config implements McpContext {
overageStrategy: OverageStrategy;
};
private readonly enableAgents: boolean;
private agents: AgentSettings;
private readonly enableEventDrivenScheduler: boolean;
private readonly skillsSupport: boolean;
private disabledSkills: string[];
private readonly adminSkillsEnabled: boolean;
private readonly experimentalJitContext: boolean;
private readonly featureGate: FeatureGate;
private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean;
private readonly trackerEnabled: boolean;
private readonly planModeRoutingEnabled: boolean;
private readonly modelSteering: boolean;
private contextManager?: ContextManager;
private terminalBackground: string | undefined = undefined;
private remoteAdminSettings: AdminControlsSettings | undefined;
@@ -881,19 +879,17 @@ export class Config implements McpContext {
this.model = params.model;
this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false;
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? false;
this.trackerEnabled = params.tracker ?? false;
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
this.skillsSupport = params.skillsSupport ?? true;
this.disabledSkills = params.disabledSkills ?? [];
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
this.featureGate = Config.initializeFeatureGate(params);
this.modelAvailabilityService = new ModelAvailabilityService();
this.experimentalJitContext = params.experimentalJitContext ?? false;
this.modelSteering = params.modelSteering ?? false;
this.userHintService = new UserHintService(() =>
this.isModelSteeringEnabled(),
);
@@ -930,7 +926,6 @@ export class Config implements McpContext {
this.interactive = params.interactive ?? false;
this.ptyInfo = params.ptyInfo ?? 'child_process';
this.trustedFolder = params.trustedFolder;
this.directWebFetch = params.directWebFetch ?? false;
this.useRipgrep = params.useRipgrep ?? true;
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
@@ -959,7 +954,6 @@ export class Config implements McpContext {
params.enableShellOutputEfficiency ?? true;
this.shellToolInactivityTimeout =
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
this.extensionManagement = params.extensionManagement ?? true;
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this.sessionId);
this.storage.setCustomPlansDir(params.planSettings?.directory);
@@ -1088,10 +1082,65 @@ export class Config implements McpContext {
);
}
/**
* Initializes a FeatureGate with the following precedence (highest to lowest):
* 1. CLI flags (params.featureGates)
* 2. Environment variable (GEMINI_FEATURE_GATES)
* 3. User settings (params.features)
* 4. Legacy experimental settings
*/
private static initializeFeatureGate(params: ConfigParameters): FeatureGate {
const gate = DefaultFeatureGate.deepCopy();
if (params.features) {
gate.setFromMap(params.features);
}
// Map legacy experimental flags to features if not already set
const legacyMap: Record<string, boolean | undefined> = {
toolOutputMasking: params.toolOutputMasking?.enabled,
enableAgents: params.enableAgents,
extensionManagement: params.extensionManagement,
plan: params.plan,
jitContext: params.experimentalJitContext,
modelSteering: params.modelSteering,
taskTracker: params.tracker,
directWebFetch: params.directWebFetch,
gemmaModelRouter: params.gemmaModelRouter?.enabled,
};
for (const [key, value] of Object.entries(legacyMap)) {
if (value !== undefined && params.features?.[key] === undefined) {
gate.setFromMap({ [key]: value });
}
}
const envGates = process.env['GEMINI_FEATURE_GATES'];
if (envGates) {
gate.set(envGates);
}
if (params.featureGates) {
gate.set(params.featureGates);
}
return gate;
}
/**
* Returns the feature gate for querying feature status.
*/
getFeatureGate(): FeatureGate {
return this.featureGate;
}
isInitialized(): boolean {
return this.initialized;
}
/**
* Returns true if the feature is enabled.
*/
isFeatureEnabled(key: string): boolean {
return this.featureGate.enabled(key);
}
/**
* Dedups initialization requests using a shared promise that is only resolved
* once.
@@ -1115,7 +1164,7 @@ export class Config implements McpContext {
}
// 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);
@@ -1193,7 +1242,7 @@ export class Config implements McpContext {
await this.hookSystem.initialize();
}
if (this.experimentalJitContext) {
if (this.isJitContextEnabled()) {
this.contextManager = new ContextManager(this);
await this.contextManager.refresh();
}
@@ -1853,7 +1902,7 @@ export class Config implements McpContext {
}
getUserMemory(): string | HierarchicalMemory {
if (this.experimentalJitContext && this.contextManager) {
if (this.isJitContextEnabled() && this.contextManager) {
return {
global: this.contextManager.getGlobalMemory(),
extension: this.contextManager.getExtensionMemory(),
@@ -1867,7 +1916,7 @@ export class Config implements McpContext {
* Refreshes the MCP context, including memory, tools, and system instructions.
*/
async refreshMcpContext(): Promise<void> {
if (this.experimentalJitContext && this.contextManager) {
if (this.isJitContextEnabled() && this.contextManager) {
await this.contextManager.refresh();
} else {
const { refreshServerHierarchicalMemory } = await import(
@@ -1898,15 +1947,15 @@ export class Config implements McpContext {
}
isJitContextEnabled(): boolean {
return this.experimentalJitContext;
return this.isFeatureEnabled('jitContext');
}
isModelSteeringEnabled(): boolean {
return this.modelSteering;
return this.isFeatureEnabled('modelSteering');
}
getToolOutputMaskingEnabled(): boolean {
return this.toolOutputMasking.enabled;
return this.isFeatureEnabled('toolOutputMasking');
}
async getToolOutputMaskingConfig(): Promise<ToolOutputMaskingConfig> {
@@ -1930,7 +1979,7 @@ export class Config implements McpContext {
: undefined;
return {
enabled: this.toolOutputMasking.enabled,
enabled: this.getToolOutputMaskingEnabled(),
toolProtectionThreshold:
parsedProtection !== undefined && !isNaN(parsedProtection)
? parsedProtection
@@ -1945,7 +1994,7 @@ export class Config implements McpContext {
}
getGeminiMdFileCount(): number {
if (this.experimentalJitContext && this.contextManager) {
if (this.isJitContextEnabled() && this.contextManager) {
return this.contextManager.getLoadedPaths().size;
}
return this.geminiMdFileCount;
@@ -1956,7 +2005,7 @@ export class Config implements McpContext {
}
getGeminiMdFilePaths(): string[] {
if (this.experimentalJitContext && this.contextManager) {
if (this.isJitContextEnabled() && this.contextManager) {
return Array.from(this.contextManager.getLoadedPaths());
}
return this.geminiMdFilePaths;
@@ -2259,7 +2308,7 @@ export class Config implements McpContext {
}
getExtensionManagement(): boolean {
return this.extensionManagement;
return this.isFeatureEnabled('extensionManagement');
}
getExtensions(): GeminiCLIExtension[] {
@@ -2285,11 +2334,11 @@ export class Config implements McpContext {
}
isPlanEnabled(): boolean {
return this.planEnabled;
return this.isFeatureEnabled('plan');
}
isTrackerEnabled(): boolean {
return this.trackerEnabled;
return this.isFeatureEnabled('taskTracker');
}
getApprovedPlanPath(): string | undefined {
@@ -2297,7 +2346,7 @@ export class Config implements McpContext {
}
getDirectWebFetch(): boolean {
return this.directWebFetch;
return this.isFeatureEnabled('directWebFetch');
}
setApprovedPlanPath(path: string | undefined): void {
@@ -2305,7 +2354,7 @@ export class Config implements McpContext {
}
isAgentsEnabled(): boolean {
return this.enableAgents;
return this.isFeatureEnabled('enableAgents');
}
isEventDrivenSchedulerEnabled(): boolean {
@@ -2716,7 +2765,7 @@ export class Config implements McpContext {
}
getGemmaModelRouterEnabled(): boolean {
return this.gemmaModelRouter.enabled ?? false;
return this.isFeatureEnabled('gemmaModelRouter');
}
getGemmaModelRouterSettings(): GemmaModelRouterSettings {

View File

@@ -0,0 +1,362 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { it, expect, describe, vi } from 'vitest';
import { DefaultFeatureGate, FeatureStage } from './features.js';
import { debugLogger } from '../utils/debugLogger.js';
describe('FeatureGate', () => {
it('should resolve default values', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
testAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
testBeta: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
expect(gate.enabled('testAlpha')).toBe(false);
expect(gate.enabled('testBeta')).toBe(true);
});
it('should infer default values from stage', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
autoAlpha: [{ lockToDefault: false, preRelease: FeatureStage.Alpha }],
autoBeta: [{ lockToDefault: false, preRelease: FeatureStage.Beta }],
autoGA: [{ lockToDefault: true, preRelease: FeatureStage.GA }],
autoDeprecated: [
{ lockToDefault: false, preRelease: FeatureStage.Deprecated },
],
});
expect(gate.enabled('autoAlpha')).toBe(false);
expect(gate.enabled('autoBeta')).toBe(true);
expect(gate.enabled('autoGA')).toBe(true);
expect(gate.enabled('autoDeprecated')).toBe(false);
});
it('should infer lockToDefault from stage', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
autoLockedGA: [{ preRelease: FeatureStage.GA }],
autoUnlockedAlpha: [{ preRelease: FeatureStage.Alpha }],
});
gate.setFromMap({ autoLockedGA: false, autoUnlockedAlpha: true });
expect(gate.enabled('autoLockedGA')).toBe(true);
expect(gate.enabled('autoUnlockedAlpha')).toBe(true);
});
it('should keep locked Alpha feature disabled by default', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
lockedAlpha: [{ preRelease: FeatureStage.Alpha, lockToDefault: true }],
});
gate.setFromMap({ lockedAlpha: true });
expect(gate.enabled('lockedAlpha')).toBe(false);
});
it('should respect explicit default even if stage default differs', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
offBeta: [
{ default: false, lockToDefault: false, preRelease: FeatureStage.Beta },
],
onAlpha: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Alpha },
],
});
expect(gate.enabled('offBeta')).toBe(false);
expect(gate.enabled('onAlpha')).toBe(true);
});
it('should respect manual overrides', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
testAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
gate.setFromMap({ testAlpha: true });
expect(gate.enabled('testAlpha')).toBe(true);
});
it('should respect lockToDefault', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
testGA: [
{ default: true, lockToDefault: true, preRelease: FeatureStage.GA },
],
});
gate.setFromMap({ testGA: false });
expect(gate.enabled('testGA')).toBe(true);
});
it('should return feature info with metadata', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
feat1: [
{
preRelease: FeatureStage.Alpha,
since: '0.1.0',
description: 'Feature 1',
},
],
feat2: [
{
preRelease: FeatureStage.Beta,
since: '0.2.0',
until: '0.3.0',
description: 'Feature 2',
},
],
});
const info = gate.getFeatureInfo();
const feat1 = info.find((f) => f.key === 'feat1');
const feat2 = info.find((f) => f.key === 'feat2');
expect(feat1).toEqual({
key: 'feat1',
enabled: false,
stage: FeatureStage.Alpha,
since: '0.1.0',
until: undefined,
description: 'Feature 1',
issueUrl: undefined,
});
expect(feat2).toEqual({
key: 'feat2',
enabled: true,
stage: FeatureStage.Beta,
since: '0.2.0',
until: '0.3.0',
description: 'Feature 2',
issueUrl: undefined,
});
});
it('should include issueUrl in feature info', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
featWithUrl: [
{
preRelease: FeatureStage.Alpha,
issueUrl: 'https://github.com/google/gemini-cli/issues/1',
},
],
});
const info = gate.getFeatureInfo();
const feat = info.find((f) => f.key === 'featWithUrl');
expect(feat).toMatchObject({
key: 'featWithUrl',
issueUrl: 'https://github.com/google/gemini-cli/issues/1',
});
});
it('should respect allAlpha/allBeta toggles', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
alpha1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
alpha2: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
beta1: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
gate.setFromMap({ allAlpha: true, allBeta: false });
expect(gate.enabled('alpha1')).toBe(true);
expect(gate.enabled('alpha2')).toBe(true);
expect(gate.enabled('beta1')).toBe(false);
gate.setFromMap({ alpha1: false });
expect(gate.enabled('alpha1')).toBe(false);
});
it('should parse comma-separated strings', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
feat1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
feat2: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
gate.set('feat1=true,feat2=false');
expect(gate.enabled('feat1')).toBe(true);
expect(gate.enabled('feat2')).toBe(false);
});
it('should handle case-insensitive boolean values in set', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
feat1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
feat2: [
{ default: true, lockToDefault: false, preRelease: FeatureStage.Beta },
],
});
gate.set('feat1=TRUE,feat2=FaLsE');
expect(gate.enabled('feat1')).toBe(true);
expect(gate.enabled('feat2')).toBe(false);
});
it('should ignore whitespace in set', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
feat1: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
gate.set(' feat1 = true ');
expect(gate.enabled('feat1')).toBe(true);
});
it('should return default if feature is unknown', () => {
const gate = DefaultFeatureGate.deepCopy();
expect(gate.enabled('unknownFeature')).toBe(false);
});
it('should respect precedence: Lock > Override > Stage > Default', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
featLocked: [
{ default: true, lockToDefault: true, preRelease: FeatureStage.GA },
],
featAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
gate.setFromMap({ featLocked: false });
expect(gate.enabled('featLocked')).toBe(true);
gate.setFromMap({ allAlpha: true, featAlpha: false });
expect(gate.enabled('featAlpha')).toBe(false);
gate.setFromMap({
allAlpha: true,
featAlpha: undefined as unknown as boolean,
});
const gate2 = DefaultFeatureGate.deepCopy();
gate2.add({
featAlpha: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
},
],
});
gate2.setFromMap({ allAlpha: true });
expect(gate2.enabled('featAlpha')).toBe(true);
});
it('should use the latest feature spec', () => {
const gate = DefaultFeatureGate.deepCopy();
gate.add({
evolvedFeat: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Alpha,
since: '1.0',
},
{
default: true,
lockToDefault: false,
preRelease: FeatureStage.Beta,
since: '1.1',
},
],
});
expect(gate.enabled('evolvedFeat')).toBe(true);
});
it('should log warning when using deprecated feature only once', () => {
const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
const gate = DefaultFeatureGate.deepCopy();
gate.add({
deprecatedFeat: [
{
default: false,
lockToDefault: false,
preRelease: FeatureStage.Deprecated,
},
],
});
gate.setFromMap({ deprecatedFeat: true });
expect(gate.enabled('deprecatedFeat')).toBe(true);
expect(gate.enabled('deprecatedFeat')).toBe(true);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Feature "deprecatedFeat" is deprecated'),
);
warnSpy.mockRestore();
});
it('should perform deep copy of specs', () => {
const gate = DefaultFeatureGate.deepCopy();
const featKey = 'copiedFeat';
const initialSpecs = [{ preRelease: FeatureStage.Alpha }];
gate.add({ [featKey]: initialSpecs });
const copy = gate.deepCopy();
gate.add({
[featKey]: [{ preRelease: FeatureStage.Beta }],
});
expect(gate.enabled(featKey)).toBe(true);
expect(copy.enabled(featKey)).toBe(false);
});
});

View File

@@ -0,0 +1,358 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '../utils/debugLogger.js';
/**
* FeatureStage indicates the maturity level of a feature.
* Strictly aligned with Kubernetes Feature Gates.
*/
export enum FeatureStage {
/**
* Alpha features are disabled by default and may be unstable.
*/
Alpha = 'ALPHA',
/**
* Beta features are enabled by default and are considered stable.
*/
Beta = 'BETA',
/**
* GA features are stable and locked to enabled.
*/
GA = 'GA',
/**
* Deprecated features are scheduled for removal.
*/
Deprecated = 'DEPRECATED',
}
/**
* FeatureSpec defines the behavior and metadata of a feature at a specific version.
*/
export interface FeatureSpec {
/**
* Default enablement state.
* If not provided, defaults to:
* - Alpha: false
* - Beta: true
* - GA: true
* - Deprecated: false
*/
default?: boolean;
/**
* If true, the feature cannot be changed from its default value.
* Defaults to:
* - GA: true
* - Others: false
*/
lockToDefault?: boolean;
/**
* The maturity stage of the feature.
*/
preRelease: FeatureStage;
/**
* The version since this spec became valid.
*/
since?: string;
/**
* The version until which this spec is valid or scheduled for removal.
*/
until?: string;
/**
* Description of the feature.
*/
description?: string;
/**
* Link to the Lifecycle Tracking Issue on GitHub.
*/
issueUrl?: string;
}
/**
* FeatureInfo provides a summary of a feature's current state and metadata.
*/
export interface FeatureInfo {
key: string;
enabled: boolean;
stage: FeatureStage;
since?: string;
until?: string;
description?: string;
issueUrl?: string;
}
/**
* FeatureGate provides a read-only interface to query feature status.
*/
export interface FeatureGate {
/**
* Returns true if the feature is enabled.
*/
enabled(key: string): boolean;
/**
* Returns all known feature keys.
*/
knownFeatures(): string[];
/**
* Returns all features with their status and metadata.
*/
getFeatureInfo(): FeatureInfo[];
/**
* Returns a mutable copy of the current gate.
*/
deepCopy(): MutableFeatureGate;
}
/**
* MutableFeatureGate allows registering and configuring features.
*/
export interface MutableFeatureGate extends FeatureGate {
/**
* Adds new features or updates existing ones with versioned specs.
*/
add(features: Record<string, FeatureSpec[]>): void;
/**
* Sets feature states from a comma-separated string (e.g., "Foo=true,Bar=false").
*/
set(instance: string): void;
/**
* Sets feature states from a map.
*/
setFromMap(m: Record<string, boolean>): void;
}
class FeatureGateImpl implements MutableFeatureGate {
private specs: Map<string, FeatureSpec[]> = new Map();
private overrides: Map<string, boolean> = new Map();
private warnedFeatures: Set<string> = new Set();
add(features: Record<string, FeatureSpec[]>): void {
for (const [key, specs] of Object.entries(features)) {
this.specs.set(key, specs);
}
}
set(instance: string): void {
const pairs = instance.split(',');
for (const pair of pairs) {
const eqIndex = pair.indexOf('=');
if (eqIndex !== -1) {
const key = pair.slice(0, eqIndex).trim();
const value = pair.slice(eqIndex + 1).trim();
if (key) {
this.overrides.set(key, value.toLowerCase() === 'true');
}
}
}
}
setFromMap(m: Record<string, boolean>): void {
for (const [key, value] of Object.entries(m)) {
this.overrides.set(key, value);
}
}
enabled(key: string): boolean {
const specs = this.specs.get(key);
if (!specs || specs.length === 0) {
return false;
}
const latestSpec = specs[specs.length - 1];
const isLocked =
latestSpec.lockToDefault ?? latestSpec.preRelease === FeatureStage.GA;
if (isLocked) {
if (latestSpec.default !== undefined) {
return latestSpec.default;
}
return (
latestSpec.preRelease === FeatureStage.Beta ||
latestSpec.preRelease === FeatureStage.GA
);
}
const override = this.overrides.get(key);
if (override !== undefined) {
if (
latestSpec.preRelease === FeatureStage.Deprecated &&
!this.warnedFeatures.has(key)
) {
debugLogger.warn(
`[WARNING] Feature "${key}" is deprecated and will be removed in a future release.`,
);
this.warnedFeatures.add(key);
}
return override;
}
// Handle stage-wide defaults if set (e.g., allAlpha, allBeta)
if (latestSpec.preRelease === FeatureStage.Alpha) {
const allAlpha = this.overrides.get('allAlpha');
if (allAlpha !== undefined) return allAlpha;
}
if (latestSpec.preRelease === FeatureStage.Beta) {
const allBeta = this.overrides.get('allBeta');
if (allBeta !== undefined) return allBeta;
}
if (latestSpec.default !== undefined) {
return latestSpec.default;
}
// Auto-default based on stage
return (
latestSpec.preRelease === FeatureStage.Beta ||
latestSpec.preRelease === FeatureStage.GA
);
}
knownFeatures(): string[] {
return Array.from(this.specs.keys());
}
getFeatureInfo(): FeatureInfo[] {
return Array.from(this.specs.entries())
.map(([key, specs]) => {
const latestSpec = specs[specs.length - 1];
return {
key,
enabled: this.enabled(key),
stage: latestSpec.preRelease,
since: latestSpec.since,
until: latestSpec.until,
description: latestSpec.description,
issueUrl: latestSpec.issueUrl,
};
})
.sort((a, b) => a.key.localeCompare(b.key));
}
deepCopy(): MutableFeatureGate {
const copy = new FeatureGateImpl();
copy.specs = new Map(
Array.from(this.specs.entries()).map(([k, v]) => [k, [...v]]),
);
copy.overrides = new Map(this.overrides);
// warnedFeatures are not copied, we want to warn again in a new context if needed
return copy;
}
}
/**
* Global default feature gate.
*/
export const DefaultFeatureGate: MutableFeatureGate = new FeatureGateImpl();
/**
* Registry of core features.
*/
export const FeatureDefinitions: Record<string, FeatureSpec[]> = {
toolOutputMasking: [
{
preRelease: FeatureStage.Beta,
since: '0.29.0',
description: 'Enables tool output masking to save tokens.',
},
],
enableAgents: [
{
preRelease: FeatureStage.Alpha,
since: '0.21.0',
description: 'Enable local and remote subagents.',
},
],
extensionManagement: [
{
preRelease: FeatureStage.Beta,
since: '0.3.0',
description: 'Enable extension management features.',
},
],
extensionConfig: [
{
preRelease: FeatureStage.Beta,
since: '0.26.0',
description: 'Enable requesting and fetching of extension settings.',
},
],
extensionRegistry: [
{
preRelease: FeatureStage.Alpha,
since: '0.29.0',
description: 'Enable extension registry explore UI.',
},
],
extensionReloading: [
{
preRelease: FeatureStage.Alpha,
since: '0.13.0',
description:
'Enables extension loading/unloading within the CLI session.',
},
],
jitContext: [
{
preRelease: FeatureStage.Alpha,
since: '0.20.0',
description: 'Enable Just-In-Time (JIT) context loading.',
},
],
useOSC52Paste: [
{
preRelease: FeatureStage.Alpha,
since: '0.24.0',
description: 'Use OSC 52 sequence for pasting.',
},
],
plan: [
{
preRelease: FeatureStage.Alpha,
since: '0.26.0',
description: 'Enable planning features (Plan Mode and tools).',
},
],
useOSC52Copy: [
{
preRelease: FeatureStage.Alpha,
since: '0.31.0',
description: 'Use OSC 52 sequence for copying.',
},
],
taskTracker: [
{
preRelease: FeatureStage.Alpha,
since: '0.31.0',
description: 'Enable task tracker tools.',
},
],
modelSteering: [
{
preRelease: FeatureStage.Alpha,
since: '0.31.0',
description:
'Enable model steering (user hints) to guide the model during tool execution.',
},
],
directWebFetch: [
{
preRelease: FeatureStage.Alpha,
since: '0.31.0',
description: 'Enable web fetch behavior that bypasses LLM summarization.',
},
],
gemmaModelRouter: [
{
preRelease: FeatureStage.Alpha,
since: '0.25.0',
description: 'Enable Gemma model router for local model routing.',
},
],
};
// Register core features
DefaultFeatureGate.add(FeatureDefinitions);

View File

@@ -7,6 +7,7 @@
// Export config
export * from './config/config.js';
export * from './config/memory.js';
export * from './config/features.js';
export * from './config/defaultModelConfigs.js';
export * from './config/models.js';
export * from './config/constants.js';