diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index 8092e735f1..6fb6319b8f 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -8,11 +8,12 @@ # commented "# field: type" lines describe the remaining schema fields. # Values resolve as: default -> config.toml -> env overlay -> memory. -# Index (22 sections · 3 overlay(s)) +# Index (24 sections · 3 overlay(s)) # background src/agent/task/configSection.ts # cron src/app/cron/configSection.ts # defaultPermissionMode src/agent/permissionMode/configSection.ts # defaultPlanMode src/agent/plan/configSection.ts +# excludeSkillNames src/app/skillCatalog/configSection.ts # experimental src/app/flag/flag.ts # extraAgentDirs src/workspace/workspaceAgentProfileLoader/configSection.ts # extraSkillDirs src/app/skillCatalog/configSection.ts @@ -27,6 +28,7 @@ # providers src/app/kosongConfig/configSection.ts # secondaryModel src/app/kosongConfig/configSection.ts # services src/app/auth/configSection.ts +# skillSources src/app/skillCatalog/configSection.ts # subagent src/session/subagent/configSection.ts # task src/agent/task/configSection.ts # thinking src/app/kosongConfig/configSection.ts @@ -94,6 +96,14 @@ manual_tick = false default_plan_mode = false +# ########################################################################## +# excludeSkillNames (config.toml: exclude_skill_names) +# owner: src/app/skillCatalog/configSection.ts +# scope: core +# ########################################################################## + +exclude_skill_names = [] + # ########################################################################## # experimental # owner: src/app/flag/flag.ts @@ -340,6 +350,20 @@ merge_all_available_skills = true # oauth_host: string # custom_headers: record +# ########################################################################## +# skillSources (config.toml: skill_sources) +# owner: src/app/skillCatalog/configSection.ts +# scope: core +# ########################################################################## + +[skill_sources] +# workspace: boolean +# user: boolean +# explicit: boolean +# extra: boolean +# plugin: boolean +# builtin: boolean + # ########################################################################## # subagent # owner: src/session/subagent/configSection.ts diff --git a/packages/agent-core-v2/scripts/gen-config-manifest.mts b/packages/agent-core-v2/scripts/gen-config-manifest.mts index 01662774d1..e9d9d9e032 100644 --- a/packages/agent-core-v2/scripts/gen-config-manifest.mts +++ b/packages/agent-core-v2/scripts/gen-config-manifest.mts @@ -71,7 +71,7 @@ function scanSectionOwners(): Map { for (const match of source.matchAll(/registerConfigSection\(\s*(?:'([^']+)'|([A-Za-z0-9_$]+))/g)) { const ident = match[2]; const domain = match[1] ?? (ident === undefined ? undefined : constStringValue(source, ident)); - if (domain !== undefined) owners.set(domain, relative(PKG, file)); + if (domain !== undefined) owners.set(domain, relative(PKG, file).replaceAll('\\', '/')); } } return owners; @@ -88,7 +88,7 @@ function scanOverlayOwners(): Map { if (!source.includes('registerConfigOverlay(')) continue; for (const match of source.matchAll(/registerConfigOverlay\(\s*([A-Za-z0-9_$]+)/g)) { const ident = match[1]; - if (ident !== undefined) owners.set(ident, relative(PKG, file)); + if (ident !== undefined) owners.set(ident, relative(PKG, file).replaceAll('\\', '/')); } } return owners; diff --git a/packages/agent-core-v2/src/app/skillCatalog/configSection.ts b/packages/agent-core-v2/src/app/skillCatalog/configSection.ts index 4cf8ed4be3..5c993f5535 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/configSection.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/configSection.ts @@ -1,9 +1,8 @@ /** * `skillCatalog` domain — skill config sections. * - * Registers the v1-compatible top-level config domains `extraSkillDirs` and - * `mergeAllAvailableSkills`. Values stay camelCase in memory; TOML uses the - * snake_case keys `extra_skill_dirs` and `merge_all_available_skills`. + * Registers skill configuration sections. Values stay camelCase in memory; + * TOML uses snake_case keys. */ import { z } from 'zod'; @@ -25,3 +24,28 @@ export type MergeAllAvailableSkillsConfig = z.infer; + +registerConfigSection(SKILL_SOURCES_SECTION, SkillSourcesConfigSchema, { + defaultValue: {}, +}); + +export const EXCLUDE_SKILL_NAMES_SECTION = 'excludeSkillNames'; +export const ExcludeSkillNamesConfigSchema = z.array(z.string()).optional(); +export type ExcludeSkillNamesConfig = z.infer; + +registerConfigSection(EXCLUDE_SKILL_NAMES_SECTION, ExcludeSkillNamesConfigSchema, { + defaultValue: [], +}); diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts index 3ee0a16010..e81a821098 100644 --- a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts @@ -6,22 +6,29 @@ * sources by priority ONCE per handler, serializing refreshes for each * source; afterwards a source's `onDidChange` (fs watch / config section / * plugin reload) re-scans that source alone and re-fires the merged change - * event — no full rescan ever leaves the build-time load. The merged view is - * shared by every session of the handler through the - * `ISessionSkillCatalogData` seed (`sessionData()`), a live read view over - * this service. The plain-data state (`contributions`, `merged`) is - * registered into `workspaceState` (`IWorkspaceStateService`) and - * read/written through it. Bound at Workspace scope. + * event — no full rescan ever leaves the build-time load. Filters + * contributions by disabled source and excluded name through `config` after + * discovery so the policy applies uniformly to every source, including + * extra_skill_dirs. Registers plain-data state into `workspaceState` and + * provides a live read view through `ISessionSkillCatalogData`. Bound at + * Workspace scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; +import { IConfigService } from '#/app/config/config'; import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; +import { + EXCLUDE_SKILL_NAMES_SECTION, + type ExcludeSkillNamesConfig, + SKILL_SOURCES_SECTION, + type SkillSourcesConfig, +} from '#/app/skillCatalog/configSection'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import type { ISkillSource, SkillContribution } from '#/app/skillCatalog/skillSource'; -import type { SkillCatalog } from '#/app/skillCatalog/types'; +import { normalizeSkillName, type SkillCatalog } from '#/app/skillCatalog/types'; import { IUserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; import type { ISessionSkillCatalogData } from '#/session/sessionSkillCatalog/skillCatalogData'; import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; @@ -56,6 +63,7 @@ export class WorkspaceSkillCatalogService extends Disposable implements IWorkspa @IExtraFileSkillSource extra: IExtraFileSkillSource, @IWorkspaceRootSkillSource workspace: IWorkspaceRootSkillSource, @IPluginSkillSource plugin: IPluginSkillSource, + @IConfigService private readonly config: IConfigService, @IWorkspaceStateService private readonly states: IWorkspaceStateService, ) { super(); @@ -72,6 +80,17 @@ export class WorkspaceSkillCatalogService extends Disposable implements IWorkspa }), ); } + this._register( + this.config.onDidSectionChange((event) => { + if ( + event.domain === SKILL_SOURCES_SECTION || + event.domain === EXCLUDE_SKILL_NAMES_SECTION + ) { + this.remerge(); + this.onDidChangeEmitter.fire('config'); + } + }), + ); this.ready = this.loadAll(); } @@ -150,14 +169,34 @@ export class WorkspaceSkillCatalogService extends Disposable implements IWorkspa private remerge(): void { const m = new InMemorySkillCatalog(); - const ordered = [...this.contributions.values()].toSorted((a, b) => a.priority - b.priority); - for (const { c } of ordered) { - for (const skill of c.skills) m.register(skill, { replace: true }); + const disabledSources = this.disabledSourceIds(); + const excludedNames = this.excludedSkillNames(); + const ordered = [...this.contributions.entries()].toSorted(([, a], [, b]) => a.priority - b.priority); + for (const [sourceId, { c }] of ordered) { + if (disabledSources.has(sourceId)) continue; + for (const skill of c.skills) { + if (excludedNames.has(normalizeSkillName(skill.name))) continue; + m.register(skill, { replace: true }); + } m.addRoots(c.scannedRoots ?? []); m.recordSkipped(c.skipped ?? []); } this.merged = m; } + + private disabledSourceIds(): Set { + const sources = this.config.get(SKILL_SOURCES_SECTION) ?? {}; + return new Set( + Object.entries(sources) + .filter(([, enabled]) => enabled === false) + .map(([id]) => id), + ); + } + + private excludedSkillNames(): Set { + const names = this.config.get(EXCLUDE_SKILL_NAMES_SECTION) ?? []; + return new Set(names.map((name) => normalizeSkillName(name))); + } } registerScopedService( diff --git a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts index 8a372a8bd1..e76c0816b8 100644 --- a/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceSkillCatalog/skillCatalog.test.ts @@ -34,8 +34,11 @@ import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IConfigService } from '#/app/config/config'; import { + EXCLUDE_SKILL_NAMES_SECTION, EXTRA_SKILL_DIRS_SECTION, MERGE_ALL_AVAILABLE_SKILLS_SECTION, + SKILL_SOURCES_SECTION, + type SkillSourcesConfig, } from '#/app/skillCatalog/configSection'; import { BuiltinSkillSource, IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; import { IUserFileSkillSource, UserFileSkillSource } from '#/app/skillCatalog/userFileSkillSource'; @@ -67,10 +70,14 @@ const bootstrapStub = stubBootstrap('/home'); function configStub(): IConfigService & { setExtraSkillDirs(dirs: readonly string[]): void; setMergeAllAvailableSkills(value: boolean): void; + setSkillSources(value: NonNullable): void; + setExcludedSkillNames(names: readonly string[]): void; fireSectionChange(domain: string): void; } { let extraSkillDirs: readonly string[] = []; let mergeAllAvailableSkills = true; + let skillSources: NonNullable = {}; + let excludedSkillNames: readonly string[] = []; const sectionChangeListeners: Array<(event: unknown) => void> = []; return { _serviceBrand: undefined, @@ -83,6 +90,8 @@ function configStub(): IConfigService & { get: (domain: string) => { if (domain === EXTRA_SKILL_DIRS_SECTION) return [...extraSkillDirs]; if (domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) return mergeAllAvailableSkills; + if (domain === SKILL_SOURCES_SECTION) return { ...skillSources }; + if (domain === EXCLUDE_SKILL_NAMES_SECTION) return [...excludedSkillNames]; return undefined; }, inspect: () => ({ value: undefined, defaultValue: undefined, userValue: undefined, memoryValue: undefined }), @@ -97,6 +106,12 @@ function configStub(): IConfigService & { setMergeAllAvailableSkills: (value: boolean) => { mergeAllAvailableSkills = value; }, + setSkillSources: (value: NonNullable) => { + skillSources = { ...value }; + }, + setExcludedSkillNames: (names: readonly string[]) => { + excludedSkillNames = [...names]; + }, fireSectionChange: (domain: string) => { for (const listener of sectionChangeListeners) { listener({ domain, source: 'set', value: undefined, previousValue: undefined }); @@ -105,6 +120,8 @@ function configStub(): IConfigService & { } as unknown as IConfigService & { setExtraSkillDirs(dirs: readonly string[]): void; setMergeAllAvailableSkills(value: boolean): void; + setSkillSources(value: NonNullable): void; + setExcludedSkillNames(names: readonly string[]): void; fireSectionChange(domain: string): void; }; } @@ -209,7 +226,7 @@ async function withSkillCatalogWorkspace( const skillRoot = join(workDir, '.kimi-code', 'skills'); await mkdir(skillRoot, { recursive: true }); try { - await run({ workDir, skillRoot: await realpath(skillRoot) }); + await run({ workDir, skillRoot: (await realpath(skillRoot)).replaceAll('\\', '/') }); } finally { await rm(workDir, { recursive: true, force: true }); } @@ -261,6 +278,81 @@ describe('WorkspaceSkillCatalogService', () => { host.dispose(); }); + it('remerges when a source is disabled or enabled through config', async () => { + await withSkillCatalogWorkspace(async ({ workDir }) => { + class ProjectSkillDiscovery implements ISkillDiscovery { + declare readonly _serviceBrand: undefined; + + async discover(roots: readonly SkillRoot[]) { + return { + skills: roots.some((root) => root.source === 'project') + ? [stubSkill('project-only')] + : [], + skipped: [], + scannedRoots: [], + }; + } + } + + const ws = workspaceContextStub(workDir); + const { host, workspace, config } = makeHost(new ProjectSkillDiscovery(), ws); + try { + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getSkill('project-only')).toBeDefined(); + + const disabled = new Promise((resolve) => { + const subscription = catalog.onDidChange((sourceId) => { + subscription.dispose(); + resolve(sourceId); + }); + }); + config.setSkillSources({ workspace: false }); + config.fireSectionChange(SKILL_SOURCES_SECTION); + + await expect(disabled).resolves.toBe('config'); + expect(catalog.catalog.getSkill('project-only')).toBeUndefined(); + + const enabled = new Promise((resolve) => { + const subscription = catalog.onDidChange((sourceId) => { + subscription.dispose(); + resolve(sourceId); + }); + }); + config.setSkillSources({}); + config.fireSectionChange(SKILL_SOURCES_SECTION); + + await expect(enabled).resolves.toBe('config'); + expect(catalog.catalog.getSkill('project-only')).toBeDefined(); + } finally { + host.dispose(); + } + }); + }); + + it('excludes skill names case-insensitively without removing other skills', async () => { + const store = new InMemorySkillDiscovery(); + store.setProjectSkills([stubSkill('skill-a'), stubSkill('skill-b')]); + const ws = workspaceContextStub('/work'); + const { host, workspace, config } = makeHost(store, ws); + + const catalog = workspace.accessor.get(IWorkspaceSkillCatalog); + await catalog.load(); + const changed = new Promise((resolve) => { + const subscription = catalog.onDidChange((sourceId) => { + subscription.dispose(); + resolve(sourceId); + }); + }); + config.setExcludedSkillNames(['SKILL-A']); + config.fireSectionChange(EXCLUDE_SKILL_NAMES_SECTION); + + await expect(changed).resolves.toBe('config'); + expect(catalog.catalog.getSkill('skill-a')).toBeUndefined(); + expect(catalog.catalog.getSkill('skill-b')).toBeDefined(); + host.dispose(); + }); + it('registers contributions and the merged view into the workspace state container', async () => { const store = new InMemorySkillDiscovery(); store.setProjectSkills([stubSkill('project-only')]); diff --git a/packages/kap-server/src/protocol/rest-config.ts b/packages/kap-server/src/protocol/rest-config.ts index ef2b478c43..5192e7da9f 100644 --- a/packages/kap-server/src/protocol/rest-config.ts +++ b/packages/kap-server/src/protocol/rest-config.ts @@ -22,8 +22,19 @@ export const configResponseSchema = z.object({ permission: z.unknown().optional(), hooks: z.array(z.unknown()).optional(), services: z.unknown().optional(), + skill_sources: z + .object({ + workspace: z.boolean().optional(), + user: z.boolean().optional(), + explicit: z.boolean().optional(), + extra: z.boolean().optional(), + plugin: z.boolean().optional(), + builtin: z.boolean().optional(), + }) + .optional(), merge_all_available_skills: z.boolean().optional(), extra_skill_dirs: z.array(z.string()).optional(), + exclude_skill_names: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(), @@ -46,8 +57,19 @@ export const patchConfigRequestSchema = z.object({ permission: z.unknown().optional(), hooks: z.array(z.unknown()).optional(), services: z.unknown().optional(), + skill_sources: z + .object({ + workspace: z.boolean().optional(), + user: z.boolean().optional(), + explicit: z.boolean().optional(), + extra: z.boolean().optional(), + plugin: z.boolean().optional(), + builtin: z.boolean().optional(), + }) + .optional(), merge_all_available_skills: z.boolean().optional(), extra_skill_dirs: z.array(z.string()).optional(), + exclude_skill_names: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(),