diff --git a/.changeset/disabled-skills-config.md b/.changeset/disabled-skills-config.md new file mode 100644 index 0000000000..ae61b931dc --- /dev/null +++ b/.changeset/disabled-skills-config.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add `disabled_skills` config to fully hide selected skill names from Kimi (model listing, Skill tool, slash menu, and activation). Set `disabled_skills = ["name"]` in `config.toml`, then run `/reload`. diff --git a/.changeset/refresh-web-skill-menus.md b/.changeset/refresh-web-skill-menus.md new file mode 100644 index 0000000000..53b55fa83d --- /dev/null +++ b/.changeset/refresh-web-skill-menus.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Refresh loaded skill menus after configuration changes. diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index d8b1833edf..12d08d6732 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -1275,6 +1275,7 @@ export class DaemonKimiWebApi implements KimiWebApi { services: 'services', mergeAllAvailableSkills: 'merge_all_available_skills', extraSkillDirs: 'extra_skill_dirs', + disabledSkills: 'disabled_skills', loopControl: 'loop_control', background: 'background', experimental: 'experimental', diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index a9e9c03be6..0466bf1ccf 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -775,6 +775,7 @@ export function toAppConfig(wire: WireConfig): AppConfig { services: wire.services, mergeAllAvailableSkills: wire.merge_all_available_skills, extraSkillDirs: wire.extra_skill_dirs, + disabledSkills: wire.disabled_skills, loopControl: wire.loop_control, background: wire.background, experimental: wire.experimental, diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index bb87f3e3c0..cf71775031 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -415,6 +415,7 @@ export interface WireConfig { services?: unknown; merge_all_available_skills?: boolean; extra_skill_dirs?: string[]; + disabled_skills?: string[]; loop_control?: unknown; background?: unknown; experimental?: Record; diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 6c4679f165..73f2b2453f 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -672,6 +672,7 @@ export interface AppConfig { services?: unknown; mergeAllAvailableSkills?: boolean; extraSkillDirs?: string[]; + disabledSkills?: string[]; loopControl?: unknown; background?: unknown; experimental?: Record; diff --git a/apps/kimi-web/src/composables/client/useModelProviderState.ts b/apps/kimi-web/src/composables/client/useModelProviderState.ts index 62d31af0ef..2480173891 100644 --- a/apps/kimi-web/src/composables/client/useModelProviderState.ts +++ b/apps/kimi-web/src/composables/client/useModelProviderState.ts @@ -258,10 +258,16 @@ export function useModelProviderState( .catch((error: unknown) => pushOperationFailure('setConfig', error)); } + const sessionSkillRequestSeq = new Map(); + const workspaceSkillRequestSeq = new Map(); + async function loadSkillsForSession(sessionId: string): Promise { + const requestSeq = (sessionSkillRequestSeq.get(sessionId) ?? 0) + 1; + sessionSkillRequestSeq.set(sessionId, requestSeq); try { const api = getKimiWebApi(); const list = await api.listSkills(sessionId); + if (sessionSkillRequestSeq.get(sessionId) !== requestSeq) return; skillsBySession.value = { ...skillsBySession.value, [sessionId]: list }; } catch { // Skills are side data; an older daemon without /skills just yields no @@ -270,9 +276,12 @@ export function useModelProviderState( } async function loadSkillsForWorkspace(workspaceId: string): Promise { + const requestSeq = (workspaceSkillRequestSeq.get(workspaceId) ?? 0) + 1; + workspaceSkillRequestSeq.set(workspaceId, requestSeq); try { const api = getKimiWebApi(); const list = await api.listSkillsForWorkspace(workspaceId); + if (workspaceSkillRequestSeq.get(workspaceId) !== requestSeq) return; skillsByWorkspace.value = { ...skillsByWorkspace.value, [workspaceId]: list }; } catch { // Side data; an older daemon without /workspaces/{id}/skills just yields @@ -280,6 +289,13 @@ export function useModelProviderState( } } + async function refreshLoadedSkills(): Promise { + await Promise.all([ + ...Object.keys(skillsBySession.value).map(loadSkillsForSession), + ...Object.keys(skillsByWorkspace.value).map(loadSkillsForWorkspace), + ]); + } + /** Load models (cached — call again to force refresh) */ async function loadModels(): Promise { try { @@ -583,6 +599,7 @@ export function useModelProviderState( // actions loadSkillsForSession, loadSkillsForWorkspace, + refreshLoadedSkills, loadModels, loadProviders, setModel, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index f6e3f6a694..58c85cba4c 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -844,6 +844,7 @@ function applyEvent(event: ReturnType, sessionId: string, seq if (event.type === 'configChanged') { rawState.defaultModel = event.config.defaultModel ?? null; + void modelProvider.refreshLoadedSkills(); } if (event.type === 'modelCatalogChanged') { diff --git a/apps/kimi-web/test/event-batcher.test.ts b/apps/kimi-web/test/event-batcher.test.ts index 0108d92419..eef33c945f 100644 --- a/apps/kimi-web/test/event-batcher.test.ts +++ b/apps/kimi-web/test/event-batcher.test.ts @@ -576,7 +576,7 @@ describe('coalesceAppRenderEvents (lossless stream grouping)', () => { }); }); -describe('useKimiWebClient (resync integration)', () => { +describe('useKimiWebClient integration', () => { it('flushes queued deltas around an authoritative snapshot before live streaming resumes', async () => { vi.stubGlobal('WebSocket', class {}); @@ -775,6 +775,147 @@ describe('useKimiWebClient (resync integration)', () => { vi.unstubAllGlobals(); } }); + + it('refreshes loaded skills after a configChanged event', async () => { + vi.resetModules(); + vi.stubGlobal('WebSocket', vi.fn()); + + const sessionId = 'session-config'; + const session: AppSession = { + id: sessionId, + title: 'Session', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + status: 'idle', + archived: false, + currentPromptId: null, + cwd: '/workspace', + model: 'model-1', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + workspaceId: 'workspace-1', + }; + let handlers: KimiEventHandlers | undefined; + let sessionSkills = [{ name: 'before-config', description: '', source: 'project' as const }]; + const listSkills = vi.fn(async () => sessionSkills); + const connection: KimiEventConnection = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + bindNextPromptId: vi.fn(), + seedSnapshot: vi.fn(), + abort: vi.fn(), + terminalAttach: vi.fn(), + terminalInput: vi.fn(), + terminalResize: vi.fn(), + terminalDetach: vi.fn(), + terminalClose: vi.fn(), + markSideChannelAgent: vi.fn(), + health: () => ({ connected: true, open: true, stale: false }), + reconnect: vi.fn(), + close: vi.fn(), + }; + const api: Partial = { + getAuth: vi.fn(async () => ({ + ready: true, + defaultModel: 'model-1', + managedProvider: null, + })), + getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })), + getMeta: vi.fn(async () => ({ + serverVersion: '0.0.0', + serverId: 'server-1', + startedAt: '2026-01-01T00:00:00.000Z', + capabilities: {}, + openInApps: [], + dangerousBypassAuth: false, + backend: 'v2', + })), + getConfig: vi.fn(async () => ({ providers: {}, defaultModel: 'model-1' })), + listModels: vi.fn(async () => []), + listProviders: vi.fn(async () => []), + listWorkspaces: vi.fn(async () => [ + { id: 'workspace-1', root: '/workspace', name: 'Workspace', sessionCount: 1 }, + ]), + getFsHome: vi.fn(async () => ({ home: '/home/test', recentRoots: [] })), + listSessions: vi.fn(async () => ({ items: [session], hasMore: false })), + getSessionSnapshot: vi.fn(async () => ({ + asOfSeq: 0, + epoch: 'epoch-1', + session, + messages: [], + hasMoreMessages: false, + inFlightTurn: null, + subagents: [], + pendingApprovals: [], + pendingQuestions: [], + })), + getSessionStatus: vi.fn(async () => ({ + model: 'model-1', + thinkingEffort: 'high', + permission: 'manual', + planMode: false, + swarmMode: false, + contextTokens: 0, + maxContextTokens: 0, + contextUsage: 0, + })), + getSessionGoal: vi.fn(async () => null), + getSessionWarnings: vi.fn(async () => []), + getGitStatus: vi.fn(async () => ({ + branch: '', + ahead: 0, + behind: 0, + entries: {}, + additions: 0, + deletions: 0, + pullRequest: null, + })), + listTasks: vi.fn(async () => []), + listSkills, + listSkillsForWorkspace: vi.fn(async () => []), + getFileUrl: (fileId) => `file:${fileId}`, + connectEvents: vi.fn((nextHandlers) => { + handlers = nextHandlers; + return connection; + }), + }; + for (const key of Object.keys(clientApiMock)) delete clientApiMock[key]; + Object.assign(clientApiMock, api); + + try { + const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); + const client = useKimiWebClient(); + await client.load(); + await vi.waitFor(() => expect(client.skills.value[0]?.name).toBe('before-config')); + listSkills.mockClear(); + sessionSkills = [{ name: 'after-config', description: '', source: 'project' as const }]; + + handlers!.onEvent( + { + type: 'configChanged', + changedFields: ['disabledSkills'], + config: { providers: {}, defaultModel: 'model-1', disabledSkills: ['review-helper'] }, + }, + { sessionId, seq: 1 }, + ); + + await vi.waitFor(() => expect(listSkills).toHaveBeenCalledWith(sessionId)); + await vi.waitFor(() => expect(client.skills.value[0]?.name).toBe('after-config')); + } finally { + connection.close(); + vi.unstubAllGlobals(); + } + }); }); describe('isRenderEvent (queue classification)', () => { diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index d8871d9f8b..0f0bf86a55 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -10,6 +10,7 @@ import { DaemonApiError } from '../src/api/errors'; import { createInitialState } from '../src/api/daemon/eventReducer'; import { mergeWorkspaces } from '../src/lib/mergeWorkspaces'; import { loadWorkspaceNameOverrides, saveWorkspaceNameOverrides } from '../src/lib/storage'; +import { useModelProviderState } from '../src/composables/client/useModelProviderState'; import { useWorkspaceState, forgetLocalTurnState, type UseWorkspaceStateDeps } from '../src/composables/client/useWorkspaceState'; import type { ExtendedState } from '../src/composables/useKimiWebClient'; import { clearTrace, traceKeyEvent } from '../src/debug/trace'; @@ -33,6 +34,8 @@ const apiMock = vi.hoisted(() => ({ getHealth: vi.fn(), getMeta: vi.fn(), listSessions: vi.fn(), + listSkills: vi.fn(), + listSkillsForWorkspace: vi.fn(), listWorkspaces: vi.fn(), })); @@ -233,6 +236,85 @@ function task(id: string, status: AppTask['status'] = 'running'): AppTask { }; } +describe('useModelProviderState — skill cache refresh', () => { + it('reloads every session and workspace skill list already in use', async () => { + apiMock.listSkills.mockReset(); + apiMock.listSkillsForWorkspace.mockReset(); + apiMock.listSkills.mockResolvedValue([ + { name: 'fresh-session-skill', description: '', source: 'project' }, + ]); + apiMock.listSkillsForWorkspace.mockResolvedValue([ + { name: 'fresh-workspace-skill', description: '', source: 'project' }, + ]); + const provider = useModelProviderState(createState(), { + pushOperationFailure: vi.fn(), + refreshSessionStatus: vi.fn(async () => {}), + persistSessionProfile: vi.fn(async () => true), + activity: computed(() => 'running'), + updateSession: vi.fn(), + updateSessionMessages: vi.fn(), + }); + provider.skillsBySession.value = { sess_1: [] }; + provider.skillsByWorkspace.value = { workspace_1: [] }; + + await provider.refreshLoadedSkills(); + + expect(apiMock.listSkills).toHaveBeenCalledWith('sess_1'); + expect(apiMock.listSkillsForWorkspace).toHaveBeenCalledWith('workspace_1'); + expect(provider.skillsBySession.value['sess_1']?.[0]?.name).toBe('fresh-session-skill'); + expect(provider.skillsByWorkspace.value['workspace_1']?.[0]?.name).toBe( + 'fresh-workspace-skill', + ); + }); + + it('keeps newer skill caches when overlapping refreshes resolve out of order', async () => { + const skill = (name: string) => ({ name, description: '', source: 'project' as const }); + const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; + }; + const olderSession = deferred[]>(); + const newerSession = deferred[]>(); + const olderWorkspace = deferred[]>(); + const newerWorkspace = deferred[]>(); + apiMock.listSkills.mockReset(); + apiMock.listSkillsForWorkspace.mockReset(); + apiMock.listSkills + .mockImplementationOnce(() => olderSession.promise) + .mockImplementationOnce(() => newerSession.promise); + apiMock.listSkillsForWorkspace + .mockImplementationOnce(() => olderWorkspace.promise) + .mockImplementationOnce(() => newerWorkspace.promise); + const provider = useModelProviderState(createState(), { + pushOperationFailure: vi.fn(), + refreshSessionStatus: vi.fn(async () => {}), + persistSessionProfile: vi.fn(async () => true), + activity: computed(() => 'running'), + updateSession: vi.fn(), + updateSessionMessages: vi.fn(), + }); + provider.skillsBySession.value = { sess_1: [] }; + provider.skillsByWorkspace.value = { workspace_1: [] }; + + const olderRefresh = provider.refreshLoadedSkills(); + const newerRefresh = provider.refreshLoadedSkills(); + newerSession.resolve([skill('newer-session-skill')]); + newerWorkspace.resolve([skill('newer-workspace-skill')]); + await newerRefresh; + olderSession.resolve([skill('older-session-skill')]); + olderWorkspace.resolve([skill('older-workspace-skill')]); + await olderRefresh; + + expect(provider.skillsBySession.value['sess_1']?.[0]?.name).toBe('newer-session-skill'); + expect(provider.skillsByWorkspace.value['workspace_1']?.[0]?.name).toBe( + 'newer-workspace-skill', + ); + }); +}); + describe('useWorkspaceState — abortCurrentPrompt', () => { beforeEach(() => { apiMock.abortPrompt.mockReset(); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index b29cf7f2e4..83f2f06a94 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -103,6 +103,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | | `extra_skill_dirs` | `array` | — | Extra skill search directories, layered on top of the default directories | | `extra_agent_dirs` | `array` | — | Extra custom agent search directories, layered on top of the default directories | +| `disabled_skills` | `array` | `[]` | Skill names to fully disable in Kimi (model listing, Skill tool, slash menu, and user activation). Case-insensitive. Files stay on disk. Does not block `Bash` or other tools that reimplement a skill's workflow — pair with [`permission`](#permission) deny rules when needed. See [Agent Skills](../customization/skills.md#skill-locations) | | `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` | | `providers` | `table` | `{}` | API provider table → [`providers`](#providers) | | `models` | `table` | — | Model alias table → [`models`](#models) | @@ -319,6 +320,8 @@ api_key = "sk-xxx" Built-in tool names are listed in [Built-in tools](../reference/tools.md). Most built-in tools that accept rule arguments define their own matching subject, such as `Bash(command-pattern)` or `Read(path-pattern)`. `AgentSwarm`, MCP tools, and custom tools can only be matched by tool name — argument patterns are not supported for them. +**Permission mode vs deny:** `default_permission_mode` (`manual` / `yolo` / `auto`) only changes what happens when no deny rule matches. A `decision = "deny"` rule always blocks the matching tool call, including in YOLO mode. + ```toml [[permission.rules]] decision = "allow" @@ -337,6 +340,20 @@ decision = "ask" pattern = "Bash" ``` +To hide a skill from the model **and** block shell helpers that reimplement it (for example after `disabled_skills`): + +```toml +disabled_skills = ["review-helper", "legacy-helper"] + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*review-helper-cli*)" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*legacy-helper-cli*)" +``` + ::: tip MCP server declarations are configured in `~/.kimi-code/mcp.json` or the project-local `.kimi-code/mcp.json`, not in `config.toml`. The interactive configuration entry point is `/mcp-config`; see [Model Context Protocol](../customization/mcp.md). ::: diff --git a/docs/en/customization/skills.md b/docs/en/customization/skills.md index 70b36e1e8d..e9e33eba90 100644 --- a/docs/en/customization/skills.md +++ b/docs/en/customization/skills.md @@ -81,6 +81,32 @@ The Kimi-specific user Skill directory moves with `KIMI_CODE_HOME`, so isolated extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] ``` +**Disabling skills by name**: Use top-level `disabled_skills` when a Skill directory is shared with other tools (for example `~/.agents/skills/`) but you do not want selected Skills to appear in Kimi at all: + +```toml +disabled_skills = ["review-helper", "legacy-helper"] +``` + +Disabled names are case-insensitive. Matching Skills are removed from the model listing, rejected by the `Skill` tool, hidden from the slash menu, and blocked from user activation. Files remain on disk for other tools. After editing `config.toml`, run `/reload` or start a new session. + +This is stronger than frontmatter `disableModelInvocation: true` (which only blocks automatic model invocation while still allowing slash use) and stronger than a permission deny rule for `Skill(...)` (which can block the tool call but still leaves the Skill in the model listing). + +`disabled_skills` only covers the Skill surface. The agent may still reimplement a skill's workflow with other tools — for example running a helper binary via `Bash` because a handoff document or earlier conversation showed that command. To block those shell paths as well, add deny rules (deny still applies in YOLO mode): + +```toml +disabled_skills = ["review-helper", "legacy-helper"] + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*review-helper-cli*)" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*legacy-helper-cli*)" +``` + +See [Permission rules](../configuration/config-files.md#permission) for the full rule format. + **Built-in Skills** are distributed with the CLI and have the lowest priority. They provide out-of-the-box workflows for common tasks — for example, configuring MCP servers, customizing the TUI theme, and editing config files. See [Built-in skill commands](../reference/slash-commands.md#built-in-skill-commands) for the full list. ## Invoking a Skill diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index fbc68620d0..fe9faf9042 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -103,6 +103,7 @@ timeout = 5 | `merge_all_available_skills` | `boolean` | `true` | 是否合并所有目录中的 Agent Skills | | `extra_skill_dirs` | `array` | — | 额外 Skill 搜索目录,叠加到默认目录之上 | | `extra_agent_dirs` | `array` | — | 额外自定义 Agent 搜索目录,叠加到默认目录之上 | +| `disabled_skills` | `array` | `[]` | 在 Kimi 中完全禁用的 Skill 名称(模型列表、Skill 工具、斜杠菜单与用户激活)。大小写不敏感。磁盘上的文件保留。不拦截用 `Bash` 等工具「复现」Skill 工作流的行为——需要时配合 [`permission`](#permission) deny 规则。详见 [Agent Skills](../customization/skills.md#skill-存放位置) | | `telemetry` | `boolean` | `true` | 是否启用匿名遥测;显式设为 `false` 时关闭 | | `providers` | `table` | `{}` | API 供应商表 → [`providers`](#providers) | | `models` | `table` | — | 模型别名表 → [`models`](#models) | @@ -319,6 +320,8 @@ api_key = "sk-xxx" 内置工具名见[内置工具](../reference/tools.md)。大多数支持规则参数的内置工具会定义自己的匹配对象,例如 `Bash(command-pattern)` 或 `Read(path-pattern)`。`AgentSwarm`、MCP 工具和自定义工具只能按工具名匹配,不支持参数模式。 +**权限模式与 deny:** `default_permission_mode`(`manual` / `yolo` / `auto`)只影响「没有 deny 命中时」的行为。`decision = "deny"` 的规则始终拦截匹配的工具调用,包括 YOLO 模式。 + ```toml [[permission.rules]] decision = "allow" @@ -337,6 +340,20 @@ decision = "ask" pattern = "Bash" ``` +若要在隐藏 Skill 的同时拦住「用 shell 复现其工作流」的辅助命令(例如配合 `disabled_skills`): + +```toml +disabled_skills = ["review-helper", "legacy-helper"] + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*review-helper-cli*)" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*legacy-helper-cli*)" +``` + ::: tip MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-code/mcp.json` 中,不在 `config.toml` 里。交互式配置入口是 `/mcp-config`,详见 [Model Context Protocol](../customization/mcp.md)。 ::: diff --git a/docs/zh/customization/skills.md b/docs/zh/customization/skills.md index 0ee0ce4449..22597654a9 100644 --- a/docs/zh/customization/skills.md +++ b/docs/zh/customization/skills.md @@ -81,6 +81,32 @@ Kimi 专属用户级 Skill 目录会随 `KIMI_CODE_HOME` 移动,因此隔离 extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] ``` +**按名称禁用 Skill**:当 Skill 目录与其他工具共享(例如 `~/.agents/skills/`),但又不希望某些 Skill 出现在 Kimi 中时,使用顶层 `disabled_skills`: + +```toml +disabled_skills = ["review-helper", "legacy-helper"] +``` + +名称匹配不区分大小写。被禁用的 Skill 会从模型列表中移除,被 `Skill` 工具拒绝,不出现在斜杠菜单中,也无法由用户激活。磁盘上的文件会保留,供其他工具使用。修改 `config.toml` 后执行 `/reload` 或开启新会话。 + +这比 frontmatter 的 `disableModelInvocation: true` 更强(后者只阻止模型自动调用,仍允许斜杠调用),也比针对 `Skill(...)` 的 permission deny 规则更强(后者可能拦截工具调用,但 Skill 仍会出现在模型列表中)。 + +`disabled_skills` 只覆盖 Skill 表面。Agent 仍可能用其他工具「复现」Skill 的工作流——例如根据 handoff 文档或先前对话,用 `Bash` 直接跑辅助二进制。若要一并拦住这类 shell 路径,再加 deny 规则(YOLO 模式下 deny 仍然生效): + +```toml +disabled_skills = ["review-helper", "legacy-helper"] + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*review-helper-cli*)" + +[[permission.rules]] +decision = "deny" +pattern = "Bash(*legacy-helper-cli*)" +``` + +完整规则格式见 [Permission 规则](../configuration/config-files.md#permission)。 + **内置 Skills** 随 CLI 一起分发,优先级最低。它们为常见任务提供开箱即用的工作流,例如配置 MCP server、定制 TUI 主题和编辑配置文件。完整列表详见[内置 Skill 命令](../reference/slash-commands.md#内置-skill-命令)。 ## 调用 Skill diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index e972e92857..11e08700ff 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -21,6 +21,7 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart } from '#/app/plugin/types'; +import { DISABLED_SKILLS_SECTION } from '#/app/skillCatalog/configSection'; import type { SkillCatalog, SkillDefinition } from '#/app/skillCatalog/types'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; @@ -64,7 +65,7 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic ); this._register( this.skillCatalog.onDidChange((sourceId) => { - if (sourceId === PLUGIN_SKILL_SOURCE_ID) { + if (sourceId === PLUGIN_SKILL_SOURCE_ID || sourceId === DISABLED_SKILLS_SECTION) { void this.appendFreshSessionStartReminder(); } }), @@ -115,6 +116,7 @@ function renderPluginSessionStartReminder( if (catalog === undefined) return undefined; const blocks: string[] = []; for (const sessionStart of sessionStarts) { + if (catalog.isSkillDisabled(sessionStart.skillName)) continue; const skill = catalog.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); if (skill === undefined) { log?.warn('plugin sessionStart skill not found', { diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index fe6c934558..421b7056c8 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -33,7 +33,7 @@ * window); a same-name rebind keeps the persisted thinking effort unless the * caller explicitly overrides it. `refreshSystemPrompt` never rejects: a * failed context build keeps the current prompt and surfaces a warning, - * because the `[tools]` config watcher fires it voided (an unhandled + * because config and skill-catalog watchers fire it voided (an unhandled * rejection would crash kap-server) and the Session tool-policy fan-out * awaits it across agents. Tool-policy entries that can never activate * anything (typo'd names, wildcards without the `mcp__` prefix, incomplete @@ -70,6 +70,7 @@ import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/a import { ErrorCodes, Error2 } from "#/errors"; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { DISABLED_SKILLS_SECTION } from '#/app/skillCatalog/configSection'; import type { LoopControl } from '#/agent/loop/configSection'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -191,6 +192,13 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } }), ); + this._register( + this.skillCatalog.onDidChange((sourceId) => { + if (sourceId === DISABLED_SKILLS_SECTION) { + void this.refreshSystemPrompt(); + } + }), + ); } configure(options: ProfileServiceOptions): void { diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index faae7e6494..2ff8944248 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -50,6 +50,12 @@ export class AgentSkillService extends Disposable implements IAgentSkillService if (skill === undefined) { throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${input.name}" was not found`); } + if (this.skillCatalog.catalog.isSkillDisabled(input.name)) { + throw new Error2( + ErrorCodes.SKILL_DISABLED, + `Skill "${skill.name}" is disabled in configuration (disabled_skills).`, + ); + } if (!isUserActivatableSkillType(skill.metadata.type)) { throw new Error2( ErrorCodes.SKILL_TYPE_UNSUPPORTED, diff --git a/packages/agent-core-v2/src/agent/skill/tools/skill.ts b/packages/agent-core-v2/src/agent/skill/tools/skill.ts index 8c01a0bc90..2ee8c69a2e 100644 --- a/packages/agent-core-v2/src/agent/skill/tools/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/tools/skill.ts @@ -135,6 +135,11 @@ export async function executeModelSkill( if (skill === undefined) { return errorResult(`Skill "${args.skill}" not found in the current skill listing.`); } + if (catalog.catalog.isSkillDisabled(args.skill)) { + return errorResult( + `Skill "${skill.name}" is disabled in configuration (disabled_skills).`, + ); + } if (skill.metadata.disableModelInvocation === true) { return errorResult( `Skill "${args.skill}" can only be triggered by the user (model invocation is disabled).`, diff --git a/packages/agent-core-v2/src/app/skillCatalog/configSection.ts b/packages/agent-core-v2/src/app/skillCatalog/configSection.ts index bfc68dd7d9..413cc6b950 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/configSection.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/configSection.ts @@ -1,9 +1,10 @@ /** * `skillCatalog` domain (L3) — 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 the v1-compatible top-level config domains `extraSkillDirs`, + * `mergeAllAvailableSkills`, and `disabledSkills`. Values stay camelCase in + * memory; TOML uses the snake_case keys `extra_skill_dirs`, + * `merge_all_available_skills`, and `disabled_skills`. */ import { z } from 'zod'; @@ -25,3 +26,11 @@ export type MergeAllAvailableSkillsConfig = z.infer; + +registerConfigSection(DISABLED_SKILLS_SECTION, DisabledSkillsConfigSchema, { + defaultValue: [], +}); diff --git a/packages/agent-core-v2/src/app/skillCatalog/errors.ts b/packages/agent-core-v2/src/app/skillCatalog/errors.ts index 601cf2177c..45665a4fc8 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/errors.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/errors.ts @@ -9,6 +9,7 @@ export const SkillErrors = { SKILL_NOT_FOUND: 'skill.not_found', SKILL_TYPE_UNSUPPORTED: 'skill.type_unsupported', SKILL_NAME_EMPTY: 'skill.name_empty', + SKILL_DISABLED: 'skill.disabled', }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/app/skillCatalog/registry.ts b/packages/agent-core-v2/src/app/skillCatalog/registry.ts index 22d18b1ba3..0b2114efa1 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/registry.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/registry.ts @@ -18,7 +18,12 @@ import type { SkillSource, SkippedSkill, } from './types'; -import { isInlineSkillType, normalizeSkillName } from './types'; +import { + createDisabledSkillNameSet, + isDisabledSkillName, + isInlineSkillType, + normalizeSkillName, +} from './types'; const LISTING_DESC_MAX = 250; @@ -32,11 +37,21 @@ export class SkillNotFoundError extends Error { } } +export interface InMemorySkillCatalogOptions { + /** Skill names from config `disabled_skills` (case-insensitive). */ + readonly disabledSkills?: readonly string[]; +} + export class InMemorySkillCatalog implements SkillCatalog { private readonly byName = new Map(); private readonly byPluginAndName = new Map(); private readonly roots: string[] = []; private readonly skipped: SkippedSkill[] = []; + private readonly disabledSkills: ReadonlySet; + + constructor(options: InMemorySkillCatalogOptions = {}) { + this.disabledSkills = createDisabledSkillNameSet(options.disabledSkills); + } registerBuiltinSkill(skill: SkillDefinition): void { this.register(skill.source === 'builtin' ? skill : { ...skill, source: 'builtin' }); @@ -90,8 +105,14 @@ export class InMemorySkillCatalog implements SkillCatalog { ); } + isSkillDisabled(name: string): boolean { + return isDisabledSkillName(name, this.disabledSkills); + } + listSkills(): readonly SkillDefinition[] { - return [...this.byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)); + return [...this.byName.values()] + .filter((skill) => !this.isSkillDisabled(skill.name)) + .toSorted((a, b) => a.name.localeCompare(b.name)); } listInvocableSkills(): readonly SkillDefinition[] { diff --git a/packages/agent-core-v2/src/app/skillCatalog/types.ts b/packages/agent-core-v2/src/app/skillCatalog/types.ts index 210cfe62d0..ab6599ebc3 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/types.ts @@ -65,12 +65,35 @@ export interface SkillCatalog { getSkillRoots(): readonly string[]; getSkippedByPolicy(): readonly SkippedSkill[]; getModelSkillListing(): string; + /** True when the skill name is listed in config `disabled_skills`. */ + isSkillDisabled(name: string): boolean; } export function normalizeSkillName(name: string): string { return name.toLowerCase(); } +/** Build a case-insensitive set of skill names from config `disabled_skills`. */ +export function createDisabledSkillNameSet( + names: readonly string[] | undefined, +): ReadonlySet { + const set = new Set(); + if (names === undefined) return set; + for (const name of names) { + const normalized = normalizeSkillName(name.trim()); + if (normalized.length > 0) set.add(normalized); + } + return set; +} + +export function isDisabledSkillName(name: string, disabled: ReadonlySet): boolean { + return disabled.has(normalizeSkillName(name)); +} + +export function disabledSkillConfigMessage(skillName: string): string { + return `Skill "${skillName}" is disabled in configuration (disabled_skills).`; +} + export function isInlineSkillType(type: string | undefined): boolean { return type === undefined || type === 'prompt' || type === 'inline'; } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts index 5054c8b6dd..413dd5d079 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts @@ -11,7 +11,12 @@ import { Disposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/app/config/config'; import { IBuiltinSkillSource } from '#/app/skillCatalog/builtinSkillSource'; +import { + DISABLED_SKILLS_SECTION, + type DisabledSkillsConfig, +} 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'; @@ -47,12 +52,21 @@ export class SessionSkillCatalogService @IExtraFileSkillSource extra: IExtraFileSkillSource, @IWorkspaceFileSkillSource workspace: IWorkspaceFileSkillSource, @IPluginSkillSource plugin: IPluginSkillSource, + @IConfigService private readonly config: IConfigService, ) { super(); this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted((a, b) => a.priority - b.priority); for (const s of this.sources) { if (s.onDidChange) this._register(s.onDidChange(() => { void this.reloadSource(s.id); })); } + this._register( + this.config.onDidSectionChange((event) => { + if (event.domain === DISABLED_SKILLS_SECTION) { + this.remerge(); + this.onDidChangeEmitter.fire(DISABLED_SKILLS_SECTION); + } + }), + ); this.ready = this.loadAll(); } @@ -115,7 +129,9 @@ export class SessionSkillCatalogService } private remerge(): void { - const m = new InMemorySkillCatalog(); + const disabledSkills = + this.config.get(DISABLED_SKILLS_SECTION) ?? []; + const m = new InMemorySkillCatalog({ disabledSkills }); 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 }); diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index e13a86c399..ba7e26c356 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -7,7 +7,7 @@ * test/agent/plugin/agentPlugin.test.ts`. */ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { Emitter } from '#/_base/event'; @@ -231,6 +231,54 @@ describe('AgentPluginService plugin session-start wiring', () => { sinkChange.dispose(); }); + it('neutralizes the reminder when disabled_skills changes in an active session', async () => { + let catalog = new InMemorySkillCatalog(); + catalog.register(pluginSkill()); + const sinkChange = new Emitter(); + const skillCatalog: ISessionSkillCatalog = { + _serviceBrand: undefined, + get catalog() { + return catalog; + }, + ready: Promise.resolve(), + onDidChange: sinkChange.event, + load: async () => {}, + reload: async () => {}, + }; + + ctx = createTestAgent( + { autoConfigure: true }, + appService( + IPluginService, + pluginServiceStub({ + sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }], + }), + ), + skillServices(skillCatalog), + agentService( + IAgentPluginService, + new SyncDescriptor(AgentPluginService), + ), + ); + + ctx.get(IAgentPluginService); + await injectRegistered(ctx); + expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); + const activeCtx = ctx; + + catalog = new InMemorySkillCatalog({ disabledSkills: ['demo-skill'] }); + catalog.register(pluginSkill()); + sinkChange.fire('disabledSkills'); + + await vi.waitFor(() => { + expect(findPluginSessionStartMessages(activeCtx)).toHaveLength(2); + }); + const latest = messageText(findPluginSessionStartMessages(activeCtx).at(-1)!); + expect(latest).toContain('no active plugin session starts'); + expect(latest).not.toContain(' { const catalog = new InMemorySkillCatalog(); catalog.register(pluginSkill()); diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index 8e2f4545c9..a07f7407f6 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -4,7 +4,7 @@ import { join } from 'pathe'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Event } from '#/_base/event'; +import { Emitter, Event } from '#/_base/event'; import { ConfigTarget, IConfigService } from '#/app/config/config'; import { TOOLS_SECTION } from '#/agent/toolPolicy/configSection'; import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog'; @@ -725,6 +725,36 @@ describe('AgentToolPolicyService.setSessionDisabledTools', () => { expect(toolPolicy.isToolActive('Skill')).toBe(false); await vi.waitFor(() => expect(profile.getSystemPrompt()).not.toContain(skillMarker)); }); + + it('refreshes the skill listing when disabled_skills changes at runtime', async () => { + const skillMarker = 'live-disabled-skill-marker'; + const sinkChange = new Emitter(); + let listing = skillMarker; + ctx = createTestAgent( + hostEnvironmentServices(homeDir), + sessionService(ISessionSkillCatalog, { + _serviceBrand: undefined, + get catalog() { + return { getModelSkillListing: () => listing } as never; + }, + ready: Promise.resolve(), + onDidChange: sinkChange.event, + load: async () => {}, + reload: async () => {}, + }), + ); + const { profile } = profileServices(ctx); + await profile.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL }); + expect(profile.getSystemPrompt()).toContain(skillMarker); + + listing = 'No skills'; + sinkChange.fire('disabledSkills'); + + await vi.waitFor(() => { + expect(profile.getSystemPrompt()).not.toContain(skillMarker); + }); + sinkChange.dispose(); + }); }); describe('AgentToolPolicyService executor enforcement', () => { diff --git a/packages/agent-core-v2/test/agent/skill/skill.test.ts b/packages/agent-core-v2/test/agent/skill/skill.test.ts index 9c5dbfa54f..b076bd21cf 100644 --- a/packages/agent-core-v2/test/agent/skill/skill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/skill.test.ts @@ -114,6 +114,25 @@ describe('AgentSkillService', () => { await expect(svc.activate({ name: 'missing' })).rejects.toThrow(/not found/i); }); + it('activate throws for skills listed in disabled_skills config', async () => { + const disabledCatalog = new InMemorySkillCatalog({ disabledSkills: ['review-helper'] }); + disabledCatalog.register(stubSkill('review-helper')); + ix.set(ISessionSkillCatalog, { + _serviceBrand: undefined, + catalog: disabledCatalog, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + load: async () => {}, + reload: async () => {}, + } satisfies ISessionSkillCatalog); + ix.set(IAgentSkillService, new SyncDescriptor(AgentSkillService)); + + const svc = ix.get(IAgentSkillService); + await expect(svc.activate({ name: 'review-helper' })).rejects.toThrow( + /disabled in configuration \(disabled_skills\)/i, + ); + }); + it('activate waits for the catalog to be ready before resolving', async () => { let resolveReady!: () => void; const ready = new Promise((resolve) => { @@ -267,6 +286,29 @@ describe('SkillTool', () => { }); }); + it('rejects skills listed in disabled_skills config', async () => { + const disabledCatalog = new InMemorySkillCatalog({ disabledSkills: ['review-helper'] }); + disabledCatalog.register(stubSkill('review-helper')); + ix.set(ISessionSkillCatalog, { + _serviceBrand: undefined, + catalog: disabledCatalog, + ready: Promise.resolve(), + onDidChange: () => ({ dispose: () => {} }), + load: async () => {}, + reload: async () => {}, + } satisfies ISessionSkillCatalog); + + const result = await executeTool( + makeTool(ix), + toolContext({ skill: 'review-helper' }), + ); + + expect(result).toMatchObject({ + isError: true, + output: 'Skill "review-helper" is disabled in configuration (disabled_skills).', + }); + }); + it('rejects non-inline skill types in the current v1 runtime', async () => { skills.register(stubSkill('flow-only', { metadata: { type: 'flow' } })); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 5bf59b6cfc..5123253996 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -29,6 +29,7 @@ import '#/app/cron/configSection'; import type { CronConfig } from '#/app/cron/configSection'; import '#/app/skillCatalog/configSection'; import { + DISABLED_SKILLS_SECTION, EXTRA_SKILL_DIRS_SECTION, MERGE_ALL_AVAILABLE_SKILLS_SECTION, } from '#/app/skillCatalog/configSection'; @@ -467,11 +468,12 @@ describe('ConfigService env overlay (live)', () => { }); describe('skill config sections', () => { - it('registers defaults for extraSkillDirs and mergeAllAvailableSkills', () => { + it('registers defaults for extraSkillDirs, mergeAllAvailableSkills, and disabledSkills', () => { const registry = new ConfigRegistry(); expect(registry.getSection(EXTRA_SKILL_DIRS_SECTION)?.defaultValue).toEqual([]); expect(registry.getSection(MERGE_ALL_AVAILABLE_SKILLS_SECTION)?.defaultValue).toBe(true); + expect(registry.getSection(DISABLED_SKILLS_SECTION)?.defaultValue).toEqual([]); }); }); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index c5b769d679..86f21fe578 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -144,6 +144,7 @@ function skillCatalogStub(): ISessionSkillCatalog { getSkillRoots: () => [], getSkippedByPolicy: () => [], getModelSkillListing: () => '', + isSkillDisabled: () => false, }, ready: Promise.resolve(), onDidChange: () => ({ dispose: () => {} }), diff --git a/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts b/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts index f7cde21a14..2171f54e0c 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts @@ -104,6 +104,26 @@ describe('InMemorySkillCatalog skill listing', () => { }); }); +describe('InMemorySkillCatalog disabled_skills filter', () => { + it('hides disabled skill names from public lists while keeping getSkill', () => { + const registry = new InMemorySkillCatalog({ + disabledSkills: ['Review-Helper', ' legacy-helper '], + }); + registry.register(makeSkill('review-helper', 'user', 'Review helper')); + registry.register(makeSkill('legacy-helper', 'user', 'Legacy helper')); + registry.register(makeSkill('keep-me', 'user', 'Still available')); + + expect(registry.isSkillDisabled('review-helper')).toBe(true); + expect(registry.isSkillDisabled('LEGACY-HELPER')).toBe(true); + expect(registry.listSkills().map((skill) => skill.name)).toEqual(['keep-me']); + expect(registry.listInvocableSkills().map((skill) => skill.name)).toEqual(['keep-me']); + expect(registry.getModelSkillListing()).toContain('keep-me'); + expect(registry.getModelSkillListing()).not.toContain('review-helper'); + expect(registry.getModelSkillListing()).not.toContain('legacy-helper'); + expect(registry.getSkill('review-helper')?.name).toBe('review-helper'); + }); +}); + describe('InMemorySkillCatalog model skill listing', () => { it('lists only model-invocable inline skills', () => { const registry = makeRegistry([ diff --git a/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts b/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts index ed3865e9be..cbac087c01 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts @@ -171,6 +171,7 @@ describe('ToolManager SkillTool registration with a structural catalog', () => { getSkillRoots: () => ['/skills/review'], getSkippedByPolicy: () => [], getModelSkillListing: () => '- review: desc for review', + isSkillDisabled: () => false, }; ctx = createTestAgent(skillServices(skills)); profile = ctx.get(IAgentProfileService); diff --git a/packages/agent-core/src/agent/injection/plugin-session-start.ts b/packages/agent-core/src/agent/injection/plugin-session-start.ts index b7bafe412d..b321ce6598 100644 --- a/packages/agent-core/src/agent/injection/plugin-session-start.ts +++ b/packages/agent-core/src/agent/injection/plugin-session-start.ts @@ -9,6 +9,7 @@ export interface RenderPluginSessionStartReminderInput { | { getPluginSkill(pluginId: string, name: string): SkillDefinition | undefined; renderSkillPrompt(skill: SkillDefinition, args: string): string; + isSkillDisabled(name: string): boolean; } | undefined; readonly log?: { warn(message: string, payload?: unknown): void }; @@ -30,6 +31,7 @@ export function renderPluginSessionStartReminder( if (registry === undefined) return undefined; const blocks: string[] = []; for (const sessionStart of sessionStarts) { + if (registry.isSkillDisabled(sessionStart.skillName)) continue; const skill = registry.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); if (skill === undefined) { log?.warn('plugin sessionStart skill not found', { diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts index 684122fb14..fb67789831 100644 --- a/packages/agent-core/src/agent/skill/index.ts +++ b/packages/agent-core/src/agent/skill/index.ts @@ -23,6 +23,12 @@ export class SkillManager { if (skill === undefined) { throw new KimiError(ErrorCodes.SKILL_NOT_FOUND, `Skill "${input.name}" was not found`); } + if (this.registry.isSkillDisabled(input.name)) { + throw new KimiError( + ErrorCodes.SKILL_DISABLED, + `Skill "${skill.name}" is disabled in configuration (disabled_skills).`, + ); + } if (!isUserActivatableSkillType(skill.metadata.type)) { throw new KimiError(ErrorCodes.SKILL_TYPE_UNSUPPORTED, `Skill "${skill.name}" cannot be activated by the user`); } diff --git a/packages/agent-core/src/agent/skill/types.ts b/packages/agent-core/src/agent/skill/types.ts index 19413b8a2c..c56387a6e0 100644 --- a/packages/agent-core/src/agent/skill/types.ts +++ b/packages/agent-core/src/agent/skill/types.ts @@ -7,4 +7,6 @@ export interface SkillRegistry { listInvocableSkills(): readonly SkillDefinition[]; getSkillRoots(): readonly string[]; getModelSkillListing(): string; + /** True when the skill name is listed in config `disabled_skills`. */ + isSkillDisabled(name: string): boolean; } diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 16c54fd558..5e9f781c25 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -316,6 +316,7 @@ export const KimiConfigSchema = z.object({ services: ServicesConfigSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), extraSkillDirs: z.array(z.string()).optional(), + disabledSkills: z.array(z.string()).optional(), loopControl: LoopControlSchema.optional(), background: BackgroundConfigSchema.optional(), subagent: SubagentConfigSchema.optional(), @@ -360,6 +361,7 @@ export const KimiConfigPatchSchema = z services: ServicesConfigPatchSchema.optional(), mergeAllAvailableSkills: z.boolean().optional(), extraSkillDirs: z.array(z.string()).optional(), + disabledSkills: z.array(z.string()).optional(), loopControl: LoopControlPatchSchema.optional(), background: BackgroundConfigPatchSchema.optional(), subagent: SubagentConfigPatchSchema.optional(), diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index edee21a041..7c1fe098ef 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -483,6 +483,7 @@ export function configToTomlData(config: KimiConfig): Record { 'defaultPlanMode', 'mergeAllAvailableSkills', 'extraSkillDirs', + 'disabledSkills', 'telemetry', ]; for (const key of scalarFields) { diff --git a/packages/agent-core/src/errors/codes.ts b/packages/agent-core/src/errors/codes.ts index 3032f3b0ac..6546c88a50 100644 --- a/packages/agent-core/src/errors/codes.ts +++ b/packages/agent-core/src/errors/codes.ts @@ -57,6 +57,7 @@ export const ErrorCodes = { SKILL_NOT_FOUND: 'skill.not_found', SKILL_TYPE_UNSUPPORTED: 'skill.type_unsupported', SKILL_NAME_EMPTY: 'skill.name_empty', + SKILL_DISABLED: 'skill.disabled', RECORDS_WRITE_FAILED: 'records.write_failed', COMPACTION_FAILED: 'compaction.failed', @@ -353,6 +354,12 @@ export const KIMI_ERROR_INFO = { public: true, action: 'Provide a non-empty skill name.', }, + 'skill.disabled': { + title: 'Skill disabled', + retryable: false, + public: true, + action: 'Remove the skill from disabled_skills in config.toml, then reload.', + }, 'records.write_failed': { title: 'Failed to write records', diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 4b34590d38..3752acb87d 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -987,7 +987,9 @@ export class KimiCore implements PromisableMethods { mergeAllAvailableSkills: skills.mergeAllAvailableSkills, builtinDir: skills.builtinDir, }); - const registry = new SessionSkillRegistry({}); + const registry = new SessionSkillRegistry({ + disabledSkills: skills.disabledSkills, + }); await registry.loadRoots(roots); registerBuiltinSkills(registry); return registry.listSkills().map(summarizeSkill); @@ -1191,6 +1193,7 @@ export class KimiCore implements PromisableMethods { extraDirs: config.extraSkillDirs, pluginSkillRoots: this.plugins.pluginSkillRoots(), mergeAllAvailableSkills: config.mergeAllAvailableSkills, + disabledSkills: config.disabledSkills, }; } diff --git a/packages/agent-core/src/services/config/configService.ts b/packages/agent-core/src/services/config/configService.ts index 79a08e1af1..c8a3710129 100644 --- a/packages/agent-core/src/services/config/configService.ts +++ b/packages/agent-core/src/services/config/configService.ts @@ -64,6 +64,7 @@ function toConfigResponse(config: KimiConfig): ConfigResponse { services: config.services, merge_all_available_skills: config.mergeAllAvailableSkills, extra_skill_dirs: config.extraSkillDirs, + disabled_skills: config.disabledSkills, loop_control: config.loopControl, background: config.background, experimental: config.experimental, diff --git a/packages/agent-core/src/services/skill/skill.ts b/packages/agent-core/src/services/skill/skill.ts index 7d65161958..4c49250179 100644 --- a/packages/agent-core/src/services/skill/skill.ts +++ b/packages/agent-core/src/services/skill/skill.ts @@ -30,7 +30,8 @@ * shared `SessionNotFoundError` (→ 40401). * - `SkillNotFoundError` (→ 40415) when agent-core reports `skill.not_found`. * - `SkillNotActivatableError` (→ 40912) when agent-core reports - * `skill.type_unsupported` (e.g. `reference`-type skills). + * `skill.type_unsupported` (e.g. `reference`-type skills) or + * `skill.disabled`. * * **Anti-corruption**: imports `@moonshot-ai/agent-core` only for the * `createDecorator` value and the `SkillSummary` type. diff --git a/packages/agent-core/src/services/skill/skillService.ts b/packages/agent-core/src/services/skill/skillService.ts index 56bc336628..758d01cc5f 100644 --- a/packages/agent-core/src/services/skill/skillService.ts +++ b/packages/agent-core/src/services/skill/skillService.ts @@ -50,7 +50,10 @@ export class SkillService extends Disposable implements ISkillService { if (error.code === ErrorCodes.SKILL_NOT_FOUND || error.code === ErrorCodes.SKILL_NAME_EMPTY) { throw new SkillNotFoundError(skillName, error.message); } - if (error.code === ErrorCodes.SKILL_TYPE_UNSUPPORTED) { + if ( + error.code === ErrorCodes.SKILL_TYPE_UNSUPPORTED || + error.code === ErrorCodes.SKILL_DISABLED + ) { throw new SkillNotActivatableError(skillName, error.message); } } diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 63f8d6152e..f96eeb364a 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -98,6 +98,8 @@ export interface SessionSkillConfig { readonly pluginSkillRoots?: readonly SkillRoot[]; readonly mergeAllAvailableSkills?: boolean; readonly builtinDir?: string; + /** Skill names from config `disabled_skills` (case-insensitive). */ + readonly disabledSkills?: readonly string[]; } export interface AgentMeta { @@ -227,6 +229,7 @@ export class Session { this.pluginCommands = options.pluginCommands ?? []; this.skills = new SessionSkillRegistry({ sessionId: options.id, + disabledSkills: options.skills?.disabledSkills, }); this.mcp = new McpConnectionManager({ oauthService: new McpOAuthService({ kimiHomeDir: options.kimiHomeDir }), @@ -753,7 +756,6 @@ export class Session { * persisted and visible on the wire. Used by the explicit `/reload` flow after * the session has been re-resumed with reloaded plugin state. * - * When no plugin session start is currently resolvable but an earlier * When no plugin session start is currently resolvable but the context may still * carry stale plugin guidance — either an earlier `` * reminder, or a compaction summary that may have folded one in — appends a diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts index 65b207e274..f0e89f12fd 100644 --- a/packages/agent-core/src/skill/registry.ts +++ b/packages/agent-core/src/skill/registry.ts @@ -1,7 +1,12 @@ import { expandSkillParameters, skillArgumentNames } from './parser'; import { discoverSkills, type DiscoverSkillsOptions } from './scanner'; import type { SkillDefinition, SkillRoot, SkillSource, SkippedSkill } from './types'; -import { isInlineSkillType, normalizeSkillName } from './types'; +import { + createDisabledSkillNameSet, + isDisabledSkillName, + isInlineSkillType, + normalizeSkillName, +} from './types'; import type { SkillRegistry as AgentSkillRegistry } from '../agent/skill/types'; import { escapeXmlAttr } from '../utils/xml-escape'; @@ -21,6 +26,8 @@ export interface SkillRegistryOptions { readonly discover?: typeof discoverSkills; readonly onWarning?: (message: string, cause?: unknown) => void; readonly sessionId?: string; + /** Skill names from config `disabled_skills` (case-insensitive). */ + readonly disabledSkills?: readonly string[]; } export class SessionSkillRegistry implements AgentSkillRegistry { @@ -30,12 +37,14 @@ export class SessionSkillRegistry implements AgentSkillRegistry { private readonly skipped: SkippedSkill[] = []; private readonly discoverImpl: typeof discoverSkills; private readonly onWarning: (message: string, cause?: unknown) => void; + private readonly disabledSkills: ReadonlySet; readonly sessionId?: string; constructor(options: SkillRegistryOptions = {}) { this.discoverImpl = options.discover ?? discoverSkills; this.onWarning = options.onWarning ?? (() => {}); this.sessionId = options.sessionId; + this.disabledSkills = createDisabledSkillNameSet(options.disabledSkills); } async loadRoots(roots: readonly SkillRoot[]): Promise { @@ -106,8 +115,14 @@ export class SessionSkillRegistry implements AgentSkillRegistry { ); } + isSkillDisabled(name: string): boolean { + return isDisabledSkillName(name, this.disabledSkills); + } + listSkills(): readonly SkillDefinition[] { - return [...this.byName.values()].toSorted((a, b) => a.name.localeCompare(b.name)); + return [...this.byName.values()] + .filter((skill) => !this.isSkillDisabled(skill.name)) + .toSorted((a, b) => a.name.localeCompare(b.name)); } listInvocableSkills(): readonly SkillDefinition[] { diff --git a/packages/agent-core/src/skill/types.ts b/packages/agent-core/src/skill/types.ts index 7e030adf95..8bc212882e 100644 --- a/packages/agent-core/src/skill/types.ts +++ b/packages/agent-core/src/skill/types.ts @@ -62,6 +62,27 @@ export function normalizeSkillName(name: string): string { return name.toLowerCase(); } +/** Build a case-insensitive set of skill names from config `disabled_skills`. */ +export function createDisabledSkillNameSet( + names: readonly string[] | undefined, +): ReadonlySet { + const set = new Set(); + if (names === undefined) return set; + for (const name of names) { + const normalized = normalizeSkillName(name.trim()); + if (normalized.length > 0) set.add(normalized); + } + return set; +} + +export function isDisabledSkillName(name: string, disabled: ReadonlySet): boolean { + return disabled.has(normalizeSkillName(name)); +} + +export function disabledSkillConfigMessage(skillName: string): string { + return `Skill "${skillName}" is disabled in configuration (disabled_skills).`; +} + export function isInlineSkillType(type: string | undefined): boolean { return type === undefined || type === 'prompt' || type === 'inline'; } diff --git a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts index 12f10cb56c..4901db6583 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts @@ -120,6 +120,11 @@ export class SkillTool implements BuiltinTool { if (skill === undefined) { return errorResult(`Skill "${args.skill}" not found in the current skill listing.`); } + if (skills.registry.isSkillDisabled(args.skill)) { + return errorResult( + `Skill "${skill.name}" is disabled in configuration (disabled_skills).`, + ); + } if (skill.metadata.disableModelInvocation === true) { // Keep the exact wording "can only be triggered by the user" so // contract audits and integration tests stay deterministic. diff --git a/packages/agent-core/test/agent/injection/plugin-session-start.test.ts b/packages/agent-core/test/agent/injection/plugin-session-start.test.ts index 8a8993c6b6..4070bab4e9 100644 --- a/packages/agent-core/test/agent/injection/plugin-session-start.test.ts +++ b/packages/agent-core/test/agent/injection/plugin-session-start.test.ts @@ -16,6 +16,7 @@ interface StubSessionStartAgent { getSkill: (name: string) => SkillDefinition | undefined; getPluginSkill: (pluginId: string, name: string) => SkillDefinition | undefined; renderSkillPrompt: (skill: SkillDefinition, args: string) => string; + isSkillDisabled: (name: string) => boolean; }; }; log: { @@ -55,9 +56,11 @@ interface CapturedWarn { function sessionStartAgent(input: { sessionStarts: readonly EnabledPluginSessionStart[]; skills: readonly SkillDefinition[]; + disabledSkills?: readonly string[]; history?: unknown[]; }): { agent: Agent; warnings: readonly CapturedWarn[] } { const byName = new Map(input.skills.map((s) => [s.name.toLowerCase(), s])); + const disabledSkills = new Set(input.disabledSkills?.map((name) => name.toLowerCase())); const byPluginAndName = new Map( input.skills.flatMap((s) => s.plugin === undefined ? [] : [[`${s.plugin.id}\0${s.name.toLowerCase()}`, s] as const], @@ -79,6 +82,7 @@ function sessionStartAgent(input: { if (instructions === undefined) return skill.content; return `\n${instructions}\n\n\n${skill.content}`; }, + isSkillDisabled: (name) => disabledSkills.has(name.toLowerCase()), }, }, log: { @@ -125,6 +129,19 @@ describe('PluginSessionStartInjector', () => { expect(text).toContain(''); }); + it('does not inject a disabled plugin sessionStart skill', async () => { + const { agent } = sessionStartAgent({ + sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], + skills: [skill('using-superpowers', 'body', { id: 'superpowers' })], + disabledSkills: ['using-superpowers'], + }); + + const injector = new PluginSessionStartInjector(agent); + await injector.inject(); + + expect((agent.context as unknown as { history: unknown[] }).history).toEqual([]); + }); + it('does not hard-code Superpowers guidance when the skill has no plugin instructions', async () => { const { agent } = sessionStartAgent({ sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], @@ -225,6 +242,7 @@ describe('renderPluginSessionStartReminder', () => { getPluginSkill: (pluginId: string, name: string) => byPluginAndName.get(`${pluginId}\0${name.toLowerCase()}`), renderSkillPrompt: (s: SkillDefinition) => s.content, + isSkillDisabled: () => false, }; } diff --git a/packages/agent-core/test/agent/skill-tool-manager.test.ts b/packages/agent-core/test/agent/skill-tool-manager.test.ts index 51b42a74e0..e3bc95fa38 100644 --- a/packages/agent-core/test/agent/skill-tool-manager.test.ts +++ b/packages/agent-core/test/agent/skill-tool-manager.test.ts @@ -136,6 +136,7 @@ describe('ToolManager SkillTool registration', () => { listInvocableSkills: () => [skill], getSkillRoots: () => ['/skills/review'], getModelSkillListing: () => '- review: desc for review', + isSkillDisabled: () => false, }; const agent = makeAgent(skills); diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index d626f7d5fb..d1fd03e10a 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -57,6 +57,7 @@ default_permission_mode = "auto" default_plan_mode = false merge_all_available_skills = true extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] +disabled_skills = ["review-helper", "legacy-helper"] telemetry = false theme = "dark" @@ -147,6 +148,7 @@ describe('harness config TOML loader', () => { expect(config.defaultPlanMode).toBe(false); expect(config.mergeAllAvailableSkills).toBe(true); expect(config.extraSkillDirs).toEqual(['~/team-skills', '.agents/team-skills']); + expect(config.disabledSkills).toEqual(['review-helper', 'legacy-helper']); expect(config.telemetry).toBe(false); expect(config.providers['managed:kimi-code']).toMatchObject({ type: 'kimi', @@ -357,6 +359,7 @@ removed_flag = true expect(text).toContain('default_model = "kimi-code/kimi-for-coding"'); expect(text).toContain('default_permission_mode = "auto"'); expect(text).toContain('extra_skill_dirs = [ "~/team-skills", ".agents/team-skills" ]'); + expect(text).toContain('disabled_skills = [ "review-helper", "legacy-helper" ]'); expect(text).toContain('telemetry = false'); expect(text).not.toContain('default_yolo'); expect(text).toContain('[[permission.rules]]'); diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 227f498258..71ff26fd89 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -1036,6 +1036,55 @@ base_url = "https://search.example.test/v1" expect(reminders.at(-1)).toContain('supersedes any earlier plugin_session_start'); }); + it('neutralizes a stale plugin_session_start reminder when its skill becomes disabled', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + const pluginRoot = join(tmp, 'plugin'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), baseModelConfig()); + await writeSessionStartPlugin(pluginRoot, 'BODY'); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + await core.installPlugin({ source: pluginRoot }); + const created = await rpc.createSession({ + id: 'ses_runtime_reload_disabled_sessionstart', + workDir, + model: 'default-mock', + }); + await rpc.reloadSession({ + sessionId: created.id, + forcePluginSessionStartReminder: true, + }); + expect(pluginSessionStartReminders(core, created.id)).toHaveLength(1); + + await writeFile( + join(homeDir, 'config.toml'), + baseModelConfig().replace( + '\n[providers.test]', + '\ndisabled_skills = ["greeter"]\n\n[providers.test]', + ), + ); + await rpc.reloadSession({ + sessionId: created.id, + forcePluginSessionStartReminder: true, + }); + + const reminders = pluginSessionStartReminders(core, created.id); + expect(reminders).toHaveLength(2); + expect(reminders.at(-1)).toContain('no active plugin session starts'); + expect(reminders.at(-1)).not.toContain(' { tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); const homeDir = join(tmp, 'home'); diff --git a/packages/agent-core/test/services/skill-service.test.ts b/packages/agent-core/test/services/skill-service.test.ts new file mode 100644 index 0000000000..907ffd6640 --- /dev/null +++ b/packages/agent-core/test/services/skill-service.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import type { CoreRPC, ResumeSessionResult, SessionSummary } from '../../src'; +import { ErrorCodes, KimiError } from '../../src/errors'; +import { + type ICoreProcessService, + SkillNotActivatableError, + SkillService, +} from '../../src/services'; + +function fakeSession(id: string): SessionSummary { + return { + id, + workDir: '/tmp/workspace', + sessionDir: `/tmp/session-${id}`, + createdAt: 1, + updatedAt: 1, + }; +} + +function makeCore(activateError: KimiError): ICoreProcessService { + const rpc: Partial = { + listSessions: async () => [fakeSession('session-1')], + resumeSession: async () => + ({ ...fakeSession('session-1'), sessionMetadata: {}, agents: {} }) as ResumeSessionResult, + activateSkill: async () => { + throw activateError; + }, + }; + + return { + rpc: rpc as CoreRPC, + ready: async () => undefined, + dispose: () => undefined, + _serviceBrand: undefined, + }; +} + +describe('SkillService.activate', () => { + it('maps a disabled skill to SkillNotActivatableError', async () => { + const service = new SkillService( + makeCore(new KimiError(ErrorCodes.SKILL_DISABLED, 'skill is disabled')), + ); + + await expect(service.activate('session-1', 'disabled-skill')).rejects.toBeInstanceOf( + SkillNotActivatableError, + ); + }); +}); diff --git a/packages/agent-core/test/skill/registry.test.ts b/packages/agent-core/test/skill/registry.test.ts index 7e9e26056b..7e9fce6c1d 100644 --- a/packages/agent-core/test/skill/registry.test.ts +++ b/packages/agent-core/test/skill/registry.test.ts @@ -98,6 +98,29 @@ describe('skill registry prompt rendering', () => { }); }); +describe('disabled_skills config filter', () => { + it('hides disabled skill names from listSkills, listInvocableSkills, and model listing', () => { + const registry = new SessionSkillRegistry({ + disabledSkills: ['Review-Helper', ' legacy-helper '], + }); + registry.register(makeSkill('review-helper', 'user', 'Review helper')); + registry.register(makeSkill('legacy-helper', 'user', 'Legacy helper')); + registry.register(makeSkill('keep-me', 'user', 'Still available')); + + expect(registry.isSkillDisabled('review-helper')).toBe(true); + expect(registry.isSkillDisabled('LEGACY-HELPER')).toBe(true); + expect(registry.isSkillDisabled('keep-me')).toBe(false); + + expect(registry.listSkills().map((skill) => skill.name)).toEqual(['keep-me']); + expect(registry.listInvocableSkills().map((skill) => skill.name)).toEqual(['keep-me']); + expect(registry.getModelSkillListing()).toContain('keep-me'); + expect(registry.getModelSkillListing()).not.toContain('review-helper'); + expect(registry.getModelSkillListing()).not.toContain('legacy-helper'); + // Still resolvable for diagnostics / hard-deny paths. + expect(registry.getSkill('review-helper')?.name).toBe('review-helper'); + }); +}); + describe('getModelSkillListing description truncation', () => { it('keeps descriptions at or below the 250-char limit unchanged', () => { const description = 'a'.repeat(250); diff --git a/packages/agent-core/test/tools/skill-tool.test.ts b/packages/agent-core/test/tools/skill-tool.test.ts index cde4a6de98..532309a2cb 100644 --- a/packages/agent-core/test/tools/skill-tool.test.ts +++ b/packages/agent-core/test/tools/skill-tool.test.ts @@ -32,7 +32,7 @@ function skill( function registry( skills: readonly SkillDefinition[] = [], - options: { readonly sessionId?: string } = {}, + options: { readonly sessionId?: string; readonly disabledSkills?: readonly string[] } = {}, ): AgentSkillRegistry { const registry = new SessionSkillRegistry(options); for (const item of skills) { @@ -144,6 +144,17 @@ describe('SkillTool execution', () => { expect(result.output).toContain('can only be triggered by the user'); }); + it('rejects skills listed in disabled_skills config', async () => { + const tool = skillTool( + registry([skill('review-helper')], { disabledSkills: ['review-helper'] }), + ); + + const result = await execute(tool, { skill: 'review-helper' }); + + expect(result).toMatchObject({ isError: true }); + expect(result.output).toContain('disabled in configuration (disabled_skills)'); + }); + it('rejects non-inline skill types in the current v1 runtime', async () => { const methods = skillToolMethods(); const tool = skillTool(registry([skill('review', { type: 'fork' })]), methods); diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 79de1337ca..14e9db524c 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -343,6 +343,7 @@ export const kimiErrorCodeSchema = z.enum([ 'skill.not_found', 'skill.type_unsupported', 'skill.name_empty', + 'skill.disabled', 'records.write_failed', 'compaction.failed', 'compaction.unable', diff --git a/packages/kap-server/src/protocol/rest-config.ts b/packages/kap-server/src/protocol/rest-config.ts index 5dcb7d45af..1564dfbe6f 100644 --- a/packages/kap-server/src/protocol/rest-config.ts +++ b/packages/kap-server/src/protocol/rest-config.ts @@ -23,6 +23,7 @@ export const configResponseSchema = z.object({ services: z.unknown().optional(), merge_all_available_skills: z.boolean().optional(), extra_skill_dirs: z.array(z.string()).optional(), + disabled_skills: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(), @@ -46,6 +47,7 @@ export const patchConfigRequestSchema = z.object({ services: z.unknown().optional(), merge_all_available_skills: z.boolean().optional(), extra_skill_dirs: z.array(z.string()).optional(), + disabled_skills: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(), diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 7c0a1045b2..e98670e189 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -57,7 +57,7 @@ * - unknown workspace id → envelope `code: 40410 workspace.not_found`. * - not live / unknown session → envelope `code: 40401 session.not_found` (see gate above). * - `skill.not_found` / `skill.name_empty` → envelope `code: 40415 skill.not_found`. - * - `skill.type_unsupported` → envelope `code: 40912 skill.not_activatable`. + * - `skill.type_unsupported` / `skill.disabled` → envelope `code: 40912 skill.not_activatable`. * - malformed `{tail}` (bad action, bare) → envelope `code: 40001 validation.failed`. * - other errors → 50001 via the global `installErrorHandler`. * @@ -70,6 +70,7 @@ import { BUILTIN_SKILLS, + DISABLED_SKILLS_SECTION, ErrorCodes, EXTRA_SKILL_DIRS_SECTION, IAgentSkillService, @@ -93,6 +94,7 @@ import { projectRoots, promptMetadataTextFromSkill, userRoots, + type DisabledSkillsConfig, type ISessionScopeHandle, type Scope, type SkillDefinition, @@ -343,6 +345,7 @@ async function listWorkspaceSkillsForRoot( await config.ready; const runtimeOptions = core.accessor.get(ISkillCatalogRuntimeOptions); const extraSkillDirs = config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; + const disabledSkills = config.get(DISABLED_SKILLS_SECTION) ?? []; const mergeAllAvailableSkills = config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; const explicitDirs = runtimeOptions.explicitDirs ?? []; @@ -366,7 +369,9 @@ async function listWorkspaceSkillsForRoot( discovery.discover(pluginRootList), ]); - const catalog = new InMemorySkillCatalog(); + // Match the session catalog: honor top-level `disabled_skills` so workspace + // previews (e.g. web onboarding slash menu) do not surface denylisted names. + const catalog = new InMemorySkillCatalog({ disabledSkills }); const ordered = [ { skills: BUILTIN_SKILLS, priority: SKILL_SOURCE_PRIORITY.builtin }, { skills: plugin.skills, priority: SKILL_SOURCE_PRIORITY.plugin }, @@ -421,6 +426,7 @@ function sendMappedError( reply.send(errEnvelope(ErrorCode.SKILL_NOT_FOUND, err.message, requestId, err.stack)); return; case ErrorCodes.SKILL_TYPE_UNSUPPORTED: + case ErrorCodes.SKILL_DISABLED: reply.send(errEnvelope(ErrorCode.SKILL_NOT_ACTIVATABLE, err.message, requestId, err.stack)); return; } diff --git a/packages/kap-server/test/skills.test.ts b/packages/kap-server/test/skills.test.ts index 6a0286297d..a1b690f776 100644 --- a/packages/kap-server/test/skills.test.ts +++ b/packages/kap-server/test/skills.test.ts @@ -10,6 +10,8 @@ * - GET on an unknown workspace → 40410 * - POST /api/v1/sessions/{sid}/skills/{name}:activate → {activated:true, skill_name} * - POST :activate an unknown skill → 40415 + * - POST :activate a disabled skill → 40912 + * - GET workspace/session listings honor disabled_skills * - POST bare `{name}` / bogus action → 40001 * * Session skills are resolved from the per-session `ISessionSkillCatalog` (list) @@ -149,6 +151,24 @@ describe('server-v2 /api/v1 skills', () => { ); } + /** Restart the in-process server with top-level disabled_skills in config.toml. */ + async function restartWithDisabledSkills(names: readonly string[]): Promise { + await writeFile( + join(home as string, 'config.toml'), + `disabled_skills = ${JSON.stringify([...names])}\n`, + 'utf-8', + ); + await server!.close(); + server = undefined; + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + } + describe('GET /api/v1/sessions/{sid}/skills', () => { it('returns 40401 for an unknown session', async () => { const { body } = await getJson('/api/v1/sessions/nope/skills'); @@ -242,6 +262,21 @@ describe('server-v2 /api/v1 skills', () => { expect(body.code).toBe(40415); }); + it('returns 40912 when activating a skill listed in disabled_skills', async () => { + const workspaceDir = await makeWorkspaceDir(); + await seedProjectSkill(workspaceDir, 'e2e-disabled'); + await restartWithDisabledSkills(['e2e-disabled']); + + const id = await createSession(workspaceDir); + await createMainAgent(id); + + const { body } = await postJson( + `/api/v1/sessions/${id}/skills/e2e-disabled:activate`, + ); + expect(body.code).toBe(40912); + expect(body.msg).toMatch(/disabled/i); + }); + it('returns 40401 for an unknown session', async () => { const { body } = await postJson('/api/v1/sessions/nope/skills/update-config:activate'); expect(body.code).toBe(40401); @@ -298,6 +333,27 @@ describe('server-v2 /api/v1 skills', () => { expect(names(wsSkills)).toEqual(names(sessSkills)); }); + it('omits disabled_skills from both workspace and session listings', async () => { + const workspaceDir = await makeWorkspaceDir(); + await seedProjectSkill(workspaceDir, 'e2e-keep'); + await seedProjectSkill(workspaceDir, 'e2e-hidden'); + await restartWithDisabledSkills(['e2e-hidden']); + + const wid = await registerWorkspace(workspaceDir); + const sid = await createSession(workspaceDir); + + const [wsRes, sessRes] = await Promise.all([ + getJson<{ skills: SkillWire[] }>(`/api/v1/workspaces/${wid}/skills`), + getJson<{ skills: SkillWire[] }>(`/api/v1/sessions/${sid}/skills`), + ]); + const wsSkills = listSkillsResponseSchema.parse(wsRes.body.data).skills; + const sessSkills = listSkillsResponseSchema.parse(sessRes.body.data).skills; + expect(wsSkills.some((s) => s.name === 'e2e-keep')).toBe(true); + expect(wsSkills.some((s) => s.name === 'e2e-hidden')).toBe(false); + expect(sessSkills.some((s) => s.name === 'e2e-keep')).toBe(true); + expect(sessSkills.some((s) => s.name === 'e2e-hidden')).toBe(false); + }); + it('honors explicit skill dirs in workspace preview', async () => { const workspaceDir = await makeWorkspaceDir(); await seedProjectSkill(workspaceDir, 'e2e-explicit'); diff --git a/packages/kap-server/test/transport-errors.test.ts b/packages/kap-server/test/transport-errors.test.ts index 238a6963b0..fe3ac9ef5a 100644 --- a/packages/kap-server/test/transport-errors.test.ts +++ b/packages/kap-server/test/transport-errors.test.ts @@ -1,15 +1,22 @@ /** - * Scenario: `/api/v1/debug` transport error translation. - * Responsibilities: verify stable domain-to-wire mappings and the internal-error fallback. - * Wiring: real error mapper with in-process coded errors; no external boundaries. + * Scenario: `/api/v1/debug` transport error translation and v1 event error-code validation. + * Responsibilities: verify stable domain-to-wire mappings, fallback, and skill-code parity. + * Wiring: real error mapper and event schema with in-process coded errors. * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/transport-errors.test.ts`. */ import { Error2, ErrorCodes } from '@moonshot-ai/agent-core-v2'; -import { ErrorCode } from '../src/protocol/error-codes'; import { describe, expect, it } from 'vitest'; +import { ErrorCode } from '../src/protocol/error-codes'; +import { kimiErrorCodeSchema } from '../src/protocol/events-zod'; import { mapError } from '../src/transport/errors'; +describe('v1 event error-code contract', () => { + it('accepts the skill.disabled protocol code', () => { + expect(kimiErrorCodeSchema.parse('skill.disabled')).toBe('skill.disabled'); + }); +}); + describe('/api/v1/debug transport mapError', () => { it.each([ [ErrorCodes.OS_FS_NOT_FOUND, ErrorCode.FS_PATH_NOT_FOUND], diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 9484facf1f..78d25a2975 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -43,6 +43,7 @@ theme = "dark" show_thinking_stream = true merge_all_available_skills = true extra_skill_dirs = ["~/team-skills", ".agents/team-skills"] +disabled_skills = ["review-helper", "legacy-helper"] [providers.kimi-for-coding] type = "kimi" @@ -134,6 +135,7 @@ max_context_size = "large" expect(config.defaultPlanMode).toBe(false); expect(config.mergeAllAvailableSkills).toBe(true); expect(config.extraSkillDirs).toEqual(['~/team-skills', '.agents/team-skills']); + expect(config.disabledSkills).toEqual(['review-helper', 'legacy-helper']); const provider = config.providers['kimi-for-coding']; expect(provider).toMatchObject({ @@ -192,6 +194,7 @@ max_context_size = "large" expect(text).toContain('default_model = "kimi-for-coding"'); expect(text).toContain('default_permission_mode = "auto"'); expect(text).toContain('extra_skill_dirs = [ "~/team-skills", ".agents/team-skills" ]'); + expect(text).toContain('disabled_skills = [ "review-helper", "legacy-helper" ]'); expect(text).not.toContain('default_yolo'); expect(text).toContain('max_steps_per_turn = 42'); expect(text).toContain('display_name = "Kimi for Coding"'); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 6f9178d145..e84acb0c40 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -269,6 +269,7 @@ export type KimiErrorCode = | 'skill.not_found' | 'skill.type_unsupported' | 'skill.name_empty' + | 'skill.disabled' | 'records.write_failed' | 'compaction.failed' | 'compaction.unable' @@ -1203,6 +1204,7 @@ export const kimiErrorCodeSchema = z.enum([ 'skill.not_found', 'skill.type_unsupported', 'skill.name_empty', + 'skill.disabled', 'records.write_failed', 'compaction.failed', 'compaction.unable', diff --git a/packages/protocol/src/rest/config.ts b/packages/protocol/src/rest/config.ts index 5dcb7d45af..1564dfbe6f 100644 --- a/packages/protocol/src/rest/config.ts +++ b/packages/protocol/src/rest/config.ts @@ -23,6 +23,7 @@ export const configResponseSchema = z.object({ services: z.unknown().optional(), merge_all_available_skills: z.boolean().optional(), extra_skill_dirs: z.array(z.string()).optional(), + disabled_skills: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(), @@ -46,6 +47,7 @@ export const patchConfigRequestSchema = z.object({ services: z.unknown().optional(), merge_all_available_skills: z.boolean().optional(), extra_skill_dirs: z.array(z.string()).optional(), + disabled_skills: z.array(z.string()).optional(), loop_control: z.unknown().optional(), background: z.unknown().optional(), experimental: z.record(z.string(), z.boolean()).optional(),