diff --git a/packages/agent-core-v2/docs/features.md b/packages/agent-core-v2/docs/features.md index c8ccc9aea..15b83107b 100644 --- a/packages/agent-core-v2/docs/features.md +++ b/packages/agent-core-v2/docs/features.md @@ -31,7 +31,10 @@ registerFeature(PlanFeature); // import = register `Feature extends Service`, so every contribution runs through the normal two-phase construction protocol (declare contributions in the constructor; they are buffered and -flushed by the kernel). The helpers are thin compositions over the existing seams: +flushed by the kernel). A feature may also declare `static readonly meta = { ... }` — +free-form self-description that `IFeatureManager.units()` introspection carries (and +kap-server surfaces via `GET /api/v1/meta`); it defaults to `{}`. The helpers are thin +compositions over the existing seams: | Helper | Composition | Semantics | |---|---|---| diff --git a/packages/agent-core-v2/src/_base/di/fiber.ts b/packages/agent-core-v2/src/_base/di/fiber.ts index 6308adf83..aed277c56 100644 --- a/packages/agent-core-v2/src/_base/di/fiber.ts +++ b/packages/agent-core-v2/src/_base/di/fiber.ts @@ -35,6 +35,7 @@ export interface RecipeStatics { readonly name?: string; readonly inject?: readonly ServiceIdentifier[]; readonly Config?: ConfigSchema; + readonly meta?: Record; } export type ServiceClassRecipe = diff --git a/packages/agent-core-v2/src/app/feature/featureManager.ts b/packages/agent-core-v2/src/app/feature/featureManager.ts index 331331c1d..248ab0aa8 100644 --- a/packages/agent-core-v2/src/app/feature/featureManager.ts +++ b/packages/agent-core-v2/src/app/feature/featureManager.ts @@ -13,6 +13,7 @@ export interface ManagedUnitInfo { readonly name: string; readonly state: FiberState; readonly uid: number | undefined; + readonly meta: Record; } export interface IFeatureManager { diff --git a/packages/agent-core-v2/src/app/feature/featureManagerService.ts b/packages/agent-core-v2/src/app/feature/featureManagerService.ts index 4fc8de970..f44514976 100644 --- a/packages/agent-core-v2/src/app/feature/featureManagerService.ts +++ b/packages/agent-core-v2/src/app/feature/featureManagerService.ts @@ -3,6 +3,7 @@ import { Emitter, type Event } from '#/_base/event'; import type { FiberHandle, FiberProvideOptions, + RecipeStatics, ServiceClassRecipe, ServiceRecipe, } from '#/_base/di/fiber'; @@ -22,7 +23,10 @@ import { export class FeatureManagerService extends Service implements IFeatureManager { declare readonly _serviceBrand: undefined; - private readonly _units = new Map(); + private readonly _units = new Map< + string, + { handle: FiberHandle; meta: Record } + >(); private readonly _onDidChangeUnits = new Emitter(); readonly onDidChangeUnits: Event = this._onDidChangeUnits.event; @@ -50,46 +54,47 @@ export class FeatureManagerService extends Service implements IFeatureManager { : this.provide(first as ServiceRecipe, second as FiberProvideOptions | undefined); const name = handle.name; const previous = this._units.get(name); - if (previous !== undefined && previous !== handle) { - void previous.dispose(); + if (previous !== undefined && previous.handle !== handle) { + void previous.handle.dispose(); } - this._units.set(name, handle); + const statics = (isServiceIdentifier(first) ? second : first) as RecipeStatics; + this._units.set(name, { handle, meta: Object.freeze({ ...statics.meta }) }); this._onDidChangeUnits.fire(); return handle; } async unprovideUnit(name: string): Promise { - const handle = this._units.get(name); - if (handle === undefined) { + const entry = this._units.get(name); + if (entry === undefined) { return; } this._units.delete(name); try { - await handle.dispose(); + await entry.handle.dispose(); } finally { this._onDidChangeUnits.fire(); } } async updateUnit(name: string, config?: unknown): Promise { - const handle = this._units.get(name); - if (handle === undefined) { + const entry = this._units.get(name); + if (entry === undefined) { throw new Error(`feature unit '${name}' is not managed by this FeatureManager`); } - await handle.update(config); + await entry.handle.update(config); this._onDidChangeUnits.fire(); } units(): readonly ManagedUnitInfo[] { const infos: ManagedUnitInfo[] = []; - for (const [name, handle] of this._units) { + for (const [name, entry] of this._units) { let uid: number | undefined; try { - uid = handle.uid; + uid = entry.handle.uid; } catch { uid = undefined; } - infos.push({ name, state: handle.state, uid }); + infos.push({ name, state: entry.handle.state, uid, meta: entry.meta }); } return infos; } diff --git a/packages/agent-core-v2/test/app/feature/featureManager.test.ts b/packages/agent-core-v2/test/app/feature/featureManager.test.ts index 243b54898..fe74acd30 100644 --- a/packages/agent-core-v2/test/app/feature/featureManager.test.ts +++ b/packages/agent-core-v2/test/app/feature/featureManager.test.ts @@ -36,7 +36,7 @@ describe('FeatureManager — dynamic unit assembly at App scope (§5.10)', () => expect(ix.invokeFunction((a) => a.get(IGizmo)).tag).toBe('gizmo'); const infos = manager.units(); expect(infos).toHaveLength(1); - expect(infos[0]).toMatchObject({ name: 'Gizmo', state: FiberState.Active }); + expect(infos[0]).toMatchObject({ name: 'Gizmo', state: FiberState.Active, meta: {} }); expect(typeof infos[0]!.uid).toBe('number'); expect(events.length).toBe(1); @@ -72,4 +72,19 @@ describe('FeatureManager — dynamic unit assembly at App scope (§5.10)', () => ix.dispose(); await expect(Promise.resolve()).resolves.toBeUndefined(); }); + + it('carries a recipe-declared static meta into introspection', () => { + const { ix, manager } = host(); + class Documented extends Service { + static readonly meta = { summary: 'a documented unit' }; + } + manager.provideUnit(Documented); + expect(manager.units()[0]).toMatchObject({ + name: 'Documented', + meta: { summary: 'a documented unit' }, + }); + manager.provideUnit(IGizmo, Gizmo); + expect(manager.units().find((unit) => unit.name === 'Gizmo')!.meta).toEqual({}); + ix.dispose(); + }); }); diff --git a/packages/kap-server/src/protocol/rest-meta.ts b/packages/kap-server/src/protocol/rest-meta.ts index fdffe7522..5da00b154 100644 --- a/packages/kap-server/src/protocol/rest-meta.ts +++ b/packages/kap-server/src/protocol/rest-meta.ts @@ -15,6 +15,22 @@ export const metaCapabilitiesSchema = z.object({ export type MetaCapabilities = z.infer; +export const metaFeatureStateSchema = z.enum([ + 'Pending', + 'Activating', + 'Active', + 'Unloading', + 'Failed', +]); + +export const metaFeatureSchema = z.object({ + name: z.string().min(1), + state: metaFeatureStateSchema, + meta: z.record(z.string(), z.unknown()), +}); + +export type MetaFeature = z.infer; + export const metaResponseSchema = z.object({ server_version: z.string().min(1), capabilities: metaCapabilitiesSchema, @@ -25,6 +41,7 @@ export const metaResponseSchema = z.object({ experimental_flags: z.record(z.string(), z.boolean()).optional(), backend: z.enum(['v1', 'v2']).optional(), web_title: z.string().optional(), + features: z.array(metaFeatureSchema).optional(), }); export type MetaResponse = z.infer; diff --git a/packages/kap-server/src/routes/meta.ts b/packages/kap-server/src/routes/meta.ts index f8eacf43d..78153e376 100644 --- a/packages/kap-server/src/routes/meta.ts +++ b/packages/kap-server/src/routes/meta.ts @@ -1,7 +1,7 @@ import { okEnvelope } from '../envelope'; import { defineRoute } from '../middleware/defineRoute'; import { metaResponseSchema } from '../protocol/rest-meta'; -import type { MetaResponse } from '../protocol/rest-meta'; +import type { MetaFeature, MetaResponse } from '../protocol/rest-meta'; interface RouteHost { get( @@ -36,6 +36,12 @@ export interface MetaRouteOptions { * always reflects the fully loaded config (never pre-load defaults). */ readonly getExperimentalFlags: () => Record | Promise>; + /** + * Resolves the engine's current feature list at request time. Backed by + * `IFeatureManager.units()` in production, so runtime retraction or a failed + * assembly is reflected in the very next response. + */ + readonly getFeatures: () => MetaFeature[] | Promise; } export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void { @@ -69,6 +75,7 @@ export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void const data: MetaResponse = { ...staticData, experimental_flags: await opts.getExperimentalFlags(), + features: await opts.getFeatures(), }; reply.send(okEnvelope(data, req.id)); }, diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 34d30e5d0..686e3d6bc 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -1,9 +1,12 @@ import { IConfigService, type Scope } from '@moonshot-ai/agent-core-v2'; +import { FiberState } from '@moonshot-ai/agent-core-v2/_base/di/fiber'; +import { IFeatureManager } from '@moonshot-ai/agent-core-v2/app/feature/featureManager'; import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag'; import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth'; import { ulid } from 'ulid'; import { okEnvelope } from '../envelope'; +import type { MetaFeature } from '../protocol/rest-meta'; import { type IConnectionRegistry } from '../transport/ws/connectionRegistry'; import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBroadcaster'; import type { TranscriptService } from '../services/transcript/transcriptService'; @@ -110,6 +113,15 @@ export async function registerApiV1Routes( await core.accessor.get(IConfigService).ready; return core.accessor.get(IFlagService).snapshot(); }, + getFeatures: () => + core.accessor + .get(IFeatureManager) + .units() + .map((unit) => ({ + name: unit.name, + state: FiberState[unit.state] as MetaFeature['state'], + meta: unit.meta, + })), }); registerAuthRoute(apiV1 as unknown as Parameters[0], core); diff --git a/packages/kap-server/test/meta.test.ts b/packages/kap-server/test/meta.test.ts index 460f109d4..ef0606e80 100644 --- a/packages/kap-server/test/meta.test.ts +++ b/packages/kap-server/test/meta.test.ts @@ -2,6 +2,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { IFeatureManager } from '@moonshot-ai/agent-core-v2/app/feature/featureManager'; +import { getFeatureRecipes } from '@moonshot-ai/agent-core-v2/features/featureRegistry'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; @@ -151,3 +153,71 @@ describe('/api/v1/meta web_title', () => { expect(body.data.web_title).toBeUndefined(); }); }); + +describe('/api/v1/meta features', () => { + let server: RunningServer | undefined; + let home: string | undefined; + + interface FeatureWire { + name: string; + state: string; + meta: Record; + } + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + home = undefined; + } + }); + + async function boot(): Promise { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-meta-features-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + return `http://127.0.0.1:${server.port}`; + } + + async function getMetaFeatures(base: string): Promise { + const res = await authedFetch(server as RunningServer, base, '/api/v1/meta'); + expect(res.status).toBe(200); + const body = (await res.json()) as { code: number; data: { features?: FeatureWire[] } }; + expect(body.code).toBe(0); + expect(body.data.features).toBeDefined(); + return body.data.features as FeatureWire[]; + } + + it('lists every registered built-in feature as Active with an empty meta', async () => { + const base = await boot(); + const features = await getMetaFeatures(base); + const expected = getFeatureRecipes() + .map((recipe) => recipe.name) + .sort(); + expect(features.map((feature) => feature.name).sort()).toEqual(expected); + for (const feature of features) { + expect(feature.state).toBe('Active'); + expect(feature.meta).toEqual({}); + } + }); + + it('drops a feature from the response after it is unprovided at runtime', async () => { + const base = await boot(); + const before = await getMetaFeatures(base); + expect(before.some((feature) => feature.name === 'plan')).toBe(true); + + await (server as RunningServer).core.accessor.get(IFeatureManager).unprovideUnit('plan'); + + const after = await getMetaFeatures(base); + expect(after.some((feature) => feature.name === 'plan')).toBe(false); + expect(after).toHaveLength(before.length - 1); + }); +});