diff --git a/.changeset/experimental-flags.md b/.changeset/experimental-flags.md new file mode 100644 index 000000000..96de96dc6 --- /dev/null +++ b/.changeset/experimental-flags.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core": minor +--- + +Add an experimental feature-flag system: a central registry (`flags/registry.ts`) plus an env-driven resolver. Gate a feature with `flags.enabled('id')`, toggled via `KIMI_CODE_EXPERIMENTAL_` or the `KIMI_CODE_EXPERIMENTAL_FLAG` master switch. No flags are defined yet. diff --git a/AGENTS.md b/AGENTS.md index 3c344585c..994f95125 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,10 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - When a test fails because of a user modification, default to fixing the test first; do not change the implementation to satisfy an old test unless the implementation truly has a bug. - Do not sacrifice code quality for external compatibility unless the user explicitly asks for it. Breaking changes go through changesets and a `major` bump, gated by the rule below. +## Experimental Features + +- Gate a not-yet-public feature behind an experimental flag. Add the flag to the registry at `packages/agent-core/src/flags/registry.ts`, then check it with `flags.enabled('my-feature')`. Flags are env-driven and default off: `KIMI_CODE_EXPERIMENTAL_` toggles one, `KIMI_CODE_EXPERIMENTAL_FLAG` enables all. Release by flipping the entry's `default` to `true`. + ## Where to Update Instructions - Hard rules that affect almost every task: update the root `AGENTS.md`. diff --git a/apps/kimi-code/src/tui/commands/experimental-flags.ts b/apps/kimi-code/src/tui/commands/experimental-flags.ts new file mode 100644 index 000000000..e1231742c --- /dev/null +++ b/apps/kimi-code/src/tui/commands/experimental-flags.ts @@ -0,0 +1,15 @@ +import type { ExperimentalFlagMap } from '@moonshot-ai/kimi-code-sdk'; + +// Resolved experimental flags, fetched once from the core over RPC at startup and then read +// synchronously by the command palette and dispatch. App-local cache, not a source of truth. +let snapshot: ExperimentalFlagMap = {}; + +/** Replace the cached flag snapshot. Call once after fetching via `harness.getExperimentalFlags()`. */ +export function setExperimentalFlags(flags: ExperimentalFlagMap): void { + snapshot = flags; +} + +/** An `undefined` flag means "not gated" → always enabled, so callers can pass an optional flag id. */ +export function isExperimentalFlagEnabled(flag: string | undefined): boolean { + return flag === undefined || snapshot[flag] === true; +} diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 2ee577850..60178b265 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -1,3 +1,4 @@ +export * from './experimental-flags'; export * from './parse'; export * from './registry'; export * from './resolve'; diff --git a/apps/kimi-code/src/tui/commands/resolve.ts b/apps/kimi-code/src/tui/commands/resolve.ts index 2d0807bf4..a47f11409 100644 --- a/apps/kimi-code/src/tui/commands/resolve.ts +++ b/apps/kimi-code/src/tui/commands/resolve.ts @@ -4,8 +4,13 @@ import { type BuiltinSlashCommand, type BuiltinSlashCommandName, } from './registry'; +import { isExperimentalFlagEnabled } from './experimental-flags'; import { parseSlashInput } from './parse'; -import type { SlashCommandBusyReason, SlashCommandInvalidReason } from './types'; +import type { + KimiSlashCommand, + SlashCommandBusyReason, + SlashCommandInvalidReason, +} from './types'; export type SlashCommandIntent = | { readonly kind: 'not-command' } @@ -45,7 +50,11 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla if (parsed === null) return { kind: 'not-command' }; const command = findBuiltInSlashCommand(parsed.name); - if (command !== undefined) { + // `command` is a literal union where only some members carry `experimentalFlag`; widen to read it. + if ( + command !== undefined && + isExperimentalFlagEnabled((command as KimiSlashCommand).experimentalFlag) + ) { const busyReason = slashCommandBusyReason(options); if ( busyReason !== undefined && diff --git a/apps/kimi-code/src/tui/commands/types.ts b/apps/kimi-code/src/tui/commands/types.ts index cb784f84d..532a301ea 100644 --- a/apps/kimi-code/src/tui/commands/types.ts +++ b/apps/kimi-code/src/tui/commands/types.ts @@ -1,4 +1,5 @@ import type { SlashCommand } from '@earendil-works/pi-tui'; +import type { FlagId } from '@moonshot-ai/kimi-code-sdk'; export type SlashCommandAvailability = 'always' | 'idle-only'; @@ -8,6 +9,8 @@ export interface KimiSlashCommand extends SlashCom readonly description: string; readonly priority?: number; readonly availability?: SlashCommandAvailability | ((args: string) => SlashCommandAvailability); + /** When set, the command is hidden from the palette and blocked unless this flag is enabled. */ + readonly experimentalFlag?: FlagId; } export interface ParsedSlashInput { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 79622dfd5..db5bf7b8a 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -34,6 +34,8 @@ import { detectFdPath } from '#/utils/process/fd-detect'; import { BUILTIN_SLASH_COMMANDS, buildSkillSlashCommands, + isExperimentalFlagEnabled, + setExperimentalFlags, sortSlashCommands, type KimiSlashCommand, type SkillListSession, @@ -287,7 +289,10 @@ export class KimiTUI { // ========================================================================= private getSlashCommands(): readonly KimiSlashCommand[] { - return [...sortSlashCommands(BUILTIN_SLASH_COMMANDS), ...this.skillCommands]; + const builtins = sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter((command) => + isExperimentalFlagEnabled(command.experimentalFlag), + ); + return [...builtins, ...this.skillCommands]; } private setupAutocomplete(): void { @@ -380,6 +385,7 @@ export class KimiTUI { // Mount only after init() succeeds; see mountFooter(). this.mountFooter(); this.renderWelcome(); + setExperimentalFlags(await this.harness.getExperimentalFlags()); this.setupAutocomplete(); void this.loadPersistedInputHistory(); this.state.editorContainer.clear(); diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index d151bad80..07381c0bd 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -131,6 +131,7 @@ describe('resolveSlashCommandInput', () => { input: '/does-not-exist arg', }); }); + }); describe('slash command busy helpers', () => { diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 40ab4e651..ce198f283 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -200,6 +200,7 @@ function makeHarness(session = makeSession(), overrides: Record track: vi.fn(), setTelemetryContext: vi.fn(), interactiveAgentId: 'main', + getExperimentalFlags: vi.fn(async () => ({})), auth: { status: vi.fn(), login: vi.fn(), diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 570f7efe4..de33c6abd 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -132,6 +132,7 @@ function makeHarness(session = makeSession(), overrides: Record close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), + getExperimentalFlags: vi.fn(async () => ({})), auth: { status: vi.fn(async () => ({ providers: [] })), login: vi.fn(async () => {}), diff --git a/packages/agent-core/src/flags/index.ts b/packages/agent-core/src/flags/index.ts new file mode 100644 index 000000000..e37a8178e --- /dev/null +++ b/packages/agent-core/src/flags/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './registry'; +export * from './resolver'; diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts new file mode 100644 index 000000000..1e9f57b87 --- /dev/null +++ b/packages/agent-core/src/flags/registry.ts @@ -0,0 +1,16 @@ +import type { FlagDefinitionInput } from './types'; + +/** + * Experimental feature flags. Empty by default — there are no experimental features yet. + * + * To add one, append an entry and gate the feature with `flags.enabled('my-feature')`: + * { id: 'my-feature', env: 'KIMI_CODE_EXPERIMENTAL_MY_FEATURE', default: false, surface: 'both' } + * + * Keep the `as const satisfies` — it derives the literal `FlagId` union that gives `enabled()` + * autocomplete and typo-checking. `env` must start with 'KIMI_CODE_EXPERIMENTAL_', be unique, and + * not equal the master switch 'KIMI_CODE_EXPERIMENTAL_FLAG'; `id` must not be 'flag'. + */ +export const FLAG_DEFINITIONS = [] as const satisfies readonly FlagDefinitionInput[]; + +/** Literal union of registered flag ids (currently none → `never`). */ +export type FlagId = (typeof FLAG_DEFINITIONS)[number]['id']; diff --git a/packages/agent-core/src/flags/resolver.ts b/packages/agent-core/src/flags/resolver.ts new file mode 100644 index 000000000..7685cd52f --- /dev/null +++ b/packages/agent-core/src/flags/resolver.ts @@ -0,0 +1,65 @@ +import { parseBooleanEnv } from '#/config/resolve'; + +import { FLAG_DEFINITIONS, type FlagId } from './registry'; +import type { FlagDefinitionInput } from './types'; + +/** Master switch: when truthy, forces every flag on (highest priority). */ +export const MASTER_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; + +/** Shared prefix for per-feature variables. */ +export const EXPERIMENTAL_PREFIX = 'KIMI_CODE_EXPERIMENTAL_'; + +/** + * Conventional env-name generator: flag id → recommended env variable name. Only used when + * authoring a new flag (to fill `env`) and for an optional consistency test; NOT used during + * resolution. + */ +export function flagEnvKey(id: string): string { + return `${EXPERIMENTAL_PREFIX}${id.toUpperCase().replaceAll('-', '_')}`; +} + +/** + * Pure, synchronous flag resolver. State comes entirely from (env, registry) and nothing is + * cached: env is read live on every call, so a single shared instance always reflects the current + * process env. Defaults to process.env + FLAG_DEFINITIONS; tests can inject a custom env / defs. + * + * Precedence (highest wins): + * L1 master switch KIMI_CODE_EXPERIMENTAL_FLAG → every flag is on + * L2 per-feature def.env (parseBooleanEnv, may force on or off) + * L3 registry default + */ +export class FlagResolver { + private readonly env: Readonly>; + private readonly byId: ReadonlyMap; + + constructor( + env: Readonly> = process.env, + definitions: readonly FlagDefinitionInput[] = FLAG_DEFINITIONS, + ) { + this.env = env; + this.byId = new Map(definitions.map((def) => [def.id, def])); + } + + enabled(id: FlagId): boolean { + const def = this.byId.get(id); + if (def === undefined) return false; + if (parseBooleanEnv(this.env[MASTER_ENV]) === true) return true; // L1 master switch + const override = parseBooleanEnv(this.env[def.env]); // L2 per-feature + if (override !== undefined) return override; + return def.default; // L3 default + } +} + +export function createFlagResolver( + env?: Readonly>, + definitions?: readonly FlagDefinitionInput[], +): FlagResolver { + return new FlagResolver(env, definitions); +} + +/** + * Process-global flag accessor. Flags are env-driven and process-global, so a single shared + * instance (reading live process.env) is the canonical way to consult them — import this directly + * rather than constructing or injecting a resolver. + */ +export const flags = new FlagResolver(); diff --git a/packages/agent-core/src/flags/types.ts b/packages/agent-core/src/flags/types.ts new file mode 100644 index 000000000..3c6668036 --- /dev/null +++ b/packages/agent-core/src/flags/types.ts @@ -0,0 +1,19 @@ +import type { FlagId } from './registry'; + +/** Which layer consumes a flag — documentation/grouping only; not used in resolution. */ +export type FlagSurface = 'core' | 'tui' | 'both'; + +/** Shape of a registry entry (id is a loose string so `as const satisfies` can validate it). */ +export interface FlagDefinitionInput { + readonly id: string; + /** Full environment variable name, e.g. `KIMI_CODE_EXPERIMENTAL_MY_FEATURE`. Read directly by the resolver. */ + readonly env: string; + readonly default: boolean; + readonly surface: FlagSurface; +} + +/** FlagId-typed view so consumers can fetch a definition by its literal id. */ +export type FlagDefinition = FlagDefinitionInput & { readonly id: FlagId }; + +/** Resolved enabled-state of every experimental flag (flag id → enabled); used for the SDK snapshot. */ +export type ExperimentalFlagMap = Record; diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 670def093..c874d2ca2 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -2,6 +2,7 @@ export * from './agent'; export * from './session'; export * from './rpc'; export * from './config'; +export * from './flags'; export * from './session/export'; export * from './telemetry'; export * from './errors'; diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 99e244d45..504e9a309 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -4,6 +4,7 @@ import type { PermissionData, PermissionMode } from '#/agent/permission'; import type { PlanData } from '#/agent/plan'; import type { ToolInfo } from '#/agent/tool'; import type { KimiConfig, KimiConfigPatch } from '#/config'; +import type { ExperimentalFlagMap } from '#/flags'; import type { ResumeSessionResult } from '#/rpc/resumed'; import type { SessionMeta } from '#/session'; import type { BackgroundTaskInfo } from '#/tools/builtin'; @@ -307,6 +308,7 @@ type SessionAPIWithId = WithSessionId; export interface CoreAPI extends SessionAPIWithId { getCoreInfo: (payload: EmptyPayload) => CoreInfo; + getExperimentalFlags: (payload: EmptyPayload) => ExperimentalFlagMap; getKimiConfig: (payload: GetKimiConfigPayload) => KimiConfig; setKimiConfig: (payload: SetKimiConfigPayload) => KimiConfig; removeKimiProvider: (payload: RemoveKimiProviderPayload) => KimiConfig; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index a20899e34..7fa4a6bd3 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -20,6 +20,13 @@ import { type KimiConfig, type MoonshotServiceConfig, } from '../config'; +import { + FLAG_DEFINITIONS, + flags, + type ExperimentalFlagMap, + type FlagDefinitionInput, + type FlagId, +} from '../flags'; import type { Logger } from '../logging/types'; import { resolveSessionMcpConfig, type SessionMcpConfig } from '../mcp'; import { Session, type SessionMeta, type SessionSkillConfig } from '../session'; @@ -242,6 +249,11 @@ export class KimiCore implements PromisableMethods { return { version: getCoreVersion() }; } + getExperimentalFlags(): ExperimentalFlagMap { + const defs: readonly FlagDefinitionInput[] = FLAG_DEFINITIONS; + return Object.fromEntries(defs.map((def) => [def.id, flags.enabled(def.id as FlagId)])); + } + async closeSession({ sessionId }: CloseSessionPayload): Promise { const session = this.sessions.get(sessionId); if (session) { diff --git a/packages/agent-core/test/flags/resolver.test.ts b/packages/agent-core/test/flags/resolver.test.ts new file mode 100644 index 000000000..15c300483 --- /dev/null +++ b/packages/agent-core/test/flags/resolver.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + EXPERIMENTAL_PREFIX, + FLAG_DEFINITIONS, + MASTER_ENV, + createFlagResolver, + flagEnvKey, + type FlagDefinitionInput, + type FlagId, +} from '../../src/flags'; + +// Controlled fake definitions to assert the precedence matrix precisely (independent of the +// real registry contents). +const DEFS = [ + { + id: 'a-on-default', + env: 'KIMI_CODE_EXPERIMENTAL_A', + default: true, + surface: 'core', + }, + { + id: 'b-off-default', + env: 'KIMI_CODE_EXPERIMENTAL_B', + default: false, + surface: 'tui', + }, +] as const satisfies readonly FlagDefinitionInput[]; + +type Env = Record; + +function make(env: Env) { + const resolver = createFlagResolver(env, DEFS); + // The fake ids are not part of the real FlagId union, so cast to FlagId when calling. + return (id: string) => resolver.enabled(id as FlagId); +} + +describe('FlagResolver', () => { + it('L3 default: returns the registry default when env is empty', () => { + const enabled = make({}); + expect(enabled('a-on-default')).toBe(true); + expect(enabled('b-off-default')).toBe(false); + }); + + it('L2 per-feature on (lenient truthy values)', () => { + for (const v of ['1', 'true', 'yes', 'on', 'TRUE', ' On ']) { + expect(make({ KIMI_CODE_EXPERIMENTAL_B: v })('b-off-default')).toBe(true); + } + }); + + it('L2 per-feature off (lenient falsy values) overrides default=true', () => { + for (const v of ['0', 'false', 'no', 'off']) { + expect(make({ KIMI_CODE_EXPERIMENTAL_A: v })('a-on-default')).toBe(false); + } + }); + + it('L2 unparseable value falls back to default', () => { + expect(make({ KIMI_CODE_EXPERIMENTAL_B: 'maybe' })('b-off-default')).toBe(false); + expect(make({ KIMI_CODE_EXPERIMENTAL_A: 'maybe' })('a-on-default')).toBe(true); + }); + + it('L1 master switch: every flag is on when enabled (including default=false)', () => { + const enabled = make({ [MASTER_ENV]: '1' }); + expect(enabled('a-on-default')).toBe(true); + expect(enabled('b-off-default')).toBe(true); + }); + + it('L1 master switch beats an L2 per-feature off (D2)', () => { + const enabled = make({ [MASTER_ENV]: '1', KIMI_CODE_EXPERIMENTAL_A: '0' }); + expect(enabled('a-on-default')).toBe(true); + }); + + it('master switch is inactive for lenient falsy values', () => { + const enabled = make({ [MASTER_ENV]: '0' }); + expect(enabled('b-off-default')).toBe(false); + }); + + it('reads the env name declared in the registry (the declared name works, others do not)', () => { + expect(make({ KIMI_CODE_EXPERIMENTAL_B: '1' })('b-off-default')).toBe(true); + // The name mechanically derived from the id must not take effect (env is explicitly ..._B). + expect(make({ KIMI_CODE_EXPERIMENTAL_B_OFF_DEFAULT: '1' })('b-off-default')).toBe(false); + }); + + it('unknown id resolves to false (defensive)', () => { + expect(make({})('not-a-real-flag')).toBe(false); + }); + + it('flagEnvKey convention: kebab -> prefix + upper snake', () => { + expect(flagEnvKey('my-feature')).toBe('KIMI_CODE_EXPERIMENTAL_MY_FEATURE'); + }); +}); + +describe('FLAG_DEFINITIONS invariants', () => { + it('every env satisfies: prefix / unique / not the master switch', () => { + const seenEnv = new Set(); + const seenId = new Set(); + const defs: readonly FlagDefinitionInput[] = FLAG_DEFINITIONS; + for (const def of defs) { + expect(def.env.startsWith(EXPERIMENTAL_PREFIX)).toBe(true); + expect(def.env).not.toBe(MASTER_ENV); + expect(def.id).not.toBe('flag'); // reserved: would collide with the master switch + expect(seenEnv.has(def.env)).toBe(false); + expect(seenId.has(def.id)).toBe(false); + seenEnv.add(def.env); + seenId.add(def.id); + } + }); +}); diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index a3136fb48..ae3d677d2 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -44,6 +44,16 @@ export { } from '@moonshot-ai/agent-core'; export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agent-core'; +// Experimental feature flags — types only. Resolved values come from +// `KimiHarness.getExperimentalFlags()` over RPC, not from a re-exported runtime value. +export type { + ExperimentalFlagMap, + FlagDefinition, + FlagDefinitionInput, + FlagId, + FlagSurface, +} from '@moonshot-ai/agent-core'; + export type { KimiAuthLoginResult, KimiAuthLogoutResult, diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index e9b51831d..769cd3a3d 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -8,6 +8,7 @@ import { resolveKimiHome, resolveLoggingConfig, withTelemetryContext, + type ExperimentalFlagMap, type TelemetryClient, type TelemetryContextPatch, type TelemetryProperties, @@ -191,6 +192,11 @@ export class KimiHarness { return this.rpc.getConfig(options); } + /** Resolved enabled-state of every experimental flag (flag id → enabled). */ + async getExperimentalFlags(): Promise { + return this.rpc.getExperimentalFlags(); + } + async ensureConfigFile(): Promise { await ensureConfigFile(this.configPath); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index a65b3e716..7346e5a5f 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -9,6 +9,7 @@ import { type ApprovalResponse, type CoreAPI, type Event, + type ExperimentalFlagMap, type OAuthTokenProviderResolver, type QuestionRequest, type QuestionResult, @@ -197,6 +198,11 @@ export class SDKRpcClient { return rpc.getKimiConfig(input ?? {}); } + async getExperimentalFlags(): Promise { + const rpc = await this.getRpc(); + return rpc.getExperimentalFlags({}); + } + async setConfig(input: KimiConfigPatch): Promise { const rpc = await this.getRpc(); return rpc.setKimiConfig(input);