Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion packages/agent-core-v2/docs/config-manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -340,6 +350,20 @@ merge_all_available_skills = true
# oauth_host: string
# custom_headers: record<string, string>

# ##########################################################################
# 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
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/scripts/gen-config-manifest.mts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function scanSectionOwners(): Map<string, string> {
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;
Expand All @@ -88,7 +88,7 @@ function scanOverlayOwners(): Map<string, string> {
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;
Expand Down
30 changes: 27 additions & 3 deletions packages/agent-core-v2/src/app/skillCatalog/configSection.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,3 +24,28 @@ export type MergeAllAvailableSkillsConfig = z.infer<typeof MergeAllAvailableSkil
registerConfigSection(MERGE_ALL_AVAILABLE_SKILLS_SECTION, MergeAllAvailableSkillsConfigSchema, {
defaultValue: true,
});

export const SKILL_SOURCES_SECTION = 'skillSources';
export const SkillSourcesConfigSchema = 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();
export type SkillSourcesConfig = z.infer<typeof SkillSourcesConfigSchema>;

registerConfigSection(SKILL_SOURCES_SECTION, SkillSourcesConfigSchema, {
defaultValue: {},
Comment thread
Vincent-Huang-2000 marked this conversation as resolved.
});

export const EXCLUDE_SKILL_NAMES_SECTION = 'excludeSkillNames';
export const ExcludeSkillNamesConfigSchema = z.array(z.string()).optional();
export type ExcludeSkillNamesConfig = z.infer<typeof ExcludeSkillNamesConfigSchema>;

registerConfigSection(EXCLUDE_SKILL_NAMES_SECTION, ExcludeSkillNamesConfigSchema, {
defaultValue: [],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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();
}

Expand Down Expand Up @@ -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<string> {
const sources = this.config.get<SkillSourcesConfig>(SKILL_SOURCES_SECTION) ?? {};
return new Set(
Object.entries(sources)
.filter(([, enabled]) => enabled === false)
.map(([id]) => id),
);
}

private excludedSkillNames(): Set<string> {
const names = this.config.get<ExcludeSkillNamesConfig>(EXCLUDE_SKILL_NAMES_SECTION) ?? [];
return new Set(names.map((name) => normalizeSkillName(name)));
}
}

registerScopedService(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,10 +70,14 @@ const bootstrapStub = stubBootstrap('/home');
function configStub(): IConfigService & {
setExtraSkillDirs(dirs: readonly string[]): void;
setMergeAllAvailableSkills(value: boolean): void;
setSkillSources(value: NonNullable<SkillSourcesConfig>): void;
setExcludedSkillNames(names: readonly string[]): void;
fireSectionChange(domain: string): void;
} {
let extraSkillDirs: readonly string[] = [];
let mergeAllAvailableSkills = true;
let skillSources: NonNullable<SkillSourcesConfig> = {};
let excludedSkillNames: readonly string[] = [];
const sectionChangeListeners: Array<(event: unknown) => void> = [];
return {
_serviceBrand: undefined,
Expand All @@ -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 }),
Expand All @@ -97,6 +106,12 @@ function configStub(): IConfigService & {
setMergeAllAvailableSkills: (value: boolean) => {
mergeAllAvailableSkills = value;
},
setSkillSources: (value: NonNullable<SkillSourcesConfig>) => {
skillSources = { ...value };
},
setExcludedSkillNames: (names: readonly string[]) => {
excludedSkillNames = [...names];
},
fireSectionChange: (domain: string) => {
for (const listener of sectionChangeListeners) {
listener({ domain, source: 'set', value: undefined, previousValue: undefined });
Expand All @@ -105,6 +120,8 @@ function configStub(): IConfigService & {
} as unknown as IConfigService & {
setExtraSkillDirs(dirs: readonly string[]): void;
setMergeAllAvailableSkills(value: boolean): void;
setSkillSources(value: NonNullable<SkillSourcesConfig>): void;
setExcludedSkillNames(names: readonly string[]): void;
fireSectionChange(domain: string): void;
};
}
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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<string>((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<string>((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<string>((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')]);
Expand Down
Loading