From 4f47caf59d958317ecb9f8a83ac0fda89eb792ad Mon Sep 17 00:00:00 2001 From: Vincent Huang <78327336+Vincent-Huang-2000@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:07:08 +1200 Subject: [PATCH 1/3] feat(agent-core-v2): add skill source toggles and name-based exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce two runtime config sections for fine-grained skill filtering: skill_sources — a per-source boolean map (workspace, user, explicit, extra, plugin, builtin) that disables an entire skill source in the merged catalog when set to false. The source's scan and contributions storage are untouched; only the merge phase skips it. exclude_skill_names — a string array of skill names to exclude from the final merged view, compared case-insensitively via normalizeSkillName(). Changes: - configSection.ts: register skillSources (object schema, default {}) and excludeSkillNames (string array schema, default []) via registerConfigSection. - workspaceSkillCatalogService.ts: inject IConfigService; rewrite remerge() to filter by disabledSourceIds first, then by excludedSkillNames per-skill; subscribe to onDidSectionChange on both domains to remerge immediately and fire 'config' to downstream consumers. - skillCatalog.test.ts: extend configStub with new sections and helpers; add three test cases covering source disable/enable round-trip, case-insensitive name exclusion, and workspace-state registration of contributions/merged. - gen-config-manifest.mts: normalize relative() output with replaceAll('\\', '/') to fix inconsistent backslash separators on Windows. Default behavior is unchanged: with empty config both filters are no-ops, and the merged catalog is identical to before. --- .../agent-core-v2/docs/config-manifest.toml | 26 ++++- .../scripts/gen-config-manifest.mts | 4 +- .../src/app/skillCatalog/configSection.ts | 30 +++++- .../workspaceSkillCatalogService.ts | 48 +++++++++- .../skillCatalog.test.ts | 94 ++++++++++++++++++- 5 files changed, 191 insertions(+), 11 deletions(-) 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..4e7ea14998 100644 --- a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts @@ -18,10 +18,17 @@ 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,35 @@ 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); + // Filter after discovery so the policy applies to every source, including extra_skill_dirs. + 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')]); From 214169175b8fa4f3423ebe24f63f24d6c4e3ea79 Mon Sep 17 00:00:00 2001 From: Vincent Huang <78327336+Vincent-Huang-2000@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:45:14 +1200 Subject: [PATCH 2/3] chore(agent-core-v2): move skill filtering policy comment into module header --- .../workspaceSkillCatalogService.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts index 4e7ea14998..e81a821098 100644 --- a/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceSkillCatalog/workspaceSkillCatalogService.ts @@ -6,12 +6,12 @@ * 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'; @@ -172,7 +172,6 @@ export class WorkspaceSkillCatalogService extends Disposable implements IWorkspa const disabledSources = this.disabledSourceIds(); const excludedNames = this.excludedSkillNames(); const ordered = [...this.contributions.entries()].toSorted(([, a], [, b]) => a.priority - b.priority); - // Filter after discovery so the policy applies to every source, including extra_skill_dirs. for (const [sourceId, { c }] of ordered) { if (disabledSources.has(sourceId)) continue; for (const skill of c.skills) { From 1e039b1c7e057f9a978aa62c1887974477877f17 Mon Sep 17 00:00:00 2001 From: Vincent Huang <78327336+Vincent-Huang-2000@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:51:11 +1200 Subject: [PATCH 3/3] fix(kap-server): expose skill_sources and exclude_skill_names in /api/v1/config The core config registry already registers `skillSources` and `excludeSkillNames` sections (from skillCatalog/configSection.ts), but the REST protocol schemas in rest-config.ts did not list them. Zod's default strip-unknown behavior silently dropped these keys from both the GET response and the POST request body, so clients could neither read nor write these config controls through the API. Add `skill_sources` and `exclude_skill_names` to both `configResponseSchema` and `patchConfigRequestSchema`, matching the shapes registered by agent-core-v2. --- .../kap-server/src/protocol/rest-config.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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(),