diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index da30b13377..a4697284a4 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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 = { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e4c0fef6eb..c99fab9b04 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; + 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 = { + 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 { - 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 { @@ -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 { diff --git a/packages/core/src/config/features.test.ts b/packages/core/src/config/features.test.ts new file mode 100644 index 0000000000..0174ee9f0b --- /dev/null +++ b/packages/core/src/config/features.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/config/features.ts b/packages/core/src/config/features.ts new file mode 100644 index 0000000000..004a37172d --- /dev/null +++ b/packages/core/src/config/features.ts @@ -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): 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): void; +} + +class FeatureGateImpl implements MutableFeatureGate { + private specs: Map = new Map(); + private overrides: Map = new Map(); + private warnedFeatures: Set = new Set(); + + add(features: Record): 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): 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 = { + 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); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c4a9965e41..e2262c9d85 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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';