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
5 changes: 5 additions & 0 deletions .changeset/qualified-plugin-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix plugin-qualified Skill listing and invocation, and report ambiguous bare plugin Skill names.
11 changes: 10 additions & 1 deletion apps/kimi-code/test/tui/commands/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,23 @@ describe('resolveSlashCommandInput', () => {
});

it('resolves skill commands and blocks them while busy', () => {
const skillCommandMap = new Map([['skill:review', 'review']]);
const skillCommandMap = new Map([
['skill:review', 'review'],
['skill:alpha-plugin:review', 'alpha-plugin:review'],
]);

expect(resolve('/skill:review src/app.ts', { skillCommandMap })).toEqual({
kind: 'skill',
commandName: 'skill:review',
skillName: 'review',
args: 'src/app.ts',
});
expect(resolve('/skill:alpha-plugin:review src/app.ts', { skillCommandMap })).toEqual({
kind: 'skill',
commandName: 'skill:alpha-plugin:review',
skillName: 'alpha-plugin:review',
args: 'src/app.ts',
});
expect(resolve('/skill:review src/app.ts', { skillCommandMap, isStreaming: true })).toEqual({
kind: 'blocked',
commandName: 'skill:review',
Expand Down
11 changes: 11 additions & 0 deletions apps/kimi-code/test/tui/commands/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ describe('skill slash commands', () => {
expect(built.commandMap.get('mcp-config')).toBe('mcp-config');
});

it('preserves a qualified plugin name through the slash-command map', () => {
const built = buildSkillSlashCommands([
skill('alpha-plugin:review', 'prompt', { source: 'extra' }),
]);

expect(built.commands.map((command) => command.name)).toEqual([
'skill:alpha-plugin:review',
]);
expect(built.commandMap.get('skill:alpha-plugin:review')).toBe('alpha-plugin:review');
});

it('keeps sub-skills slash-invocable', () => {
const built = buildSkillSlashCommands([
skill('outer.inner', 'prompt', {
Expand Down
36 changes: 35 additions & 1 deletion packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,40 @@ export interface SessionStateSnapshot {
readonly mermaid?: string;
readonly d2?: string;
} | undefined;
resolveSkill: (name: string) => /* SkillResolution — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly kind: 'resolved';
readonly skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name: string;
readonly description: string;
readonly path: string;
readonly dir: string;
readonly content: string;
readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name?: string;
readonly description?: string;
readonly type?: string;
readonly whenToUse?: string;
readonly disableModelInvocation?: boolean;
readonly isSubSkill?: boolean;
readonly safe?: boolean;
readonly arguments?: string | readonly unknown[];
[key: string]: unknown;
};
readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin';
readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly id: string;
readonly instructions?: string;
};
readonly mermaid?: string;
readonly d2?: string;
};
readonly canonicalName: string;
} | {
readonly kind: 'not-found';
} | {
readonly kind: 'ambiguous';
readonly candidates: readonly string[];
};
renderSkillPrompt: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ {
readonly name: string;
readonly description: string;
Expand Down Expand Up @@ -1010,7 +1044,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2667": undefined;
readonly "__@mediaStripSnapshotBrand@2681": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {
Expand Down
23 changes: 17 additions & 6 deletions packages/agent-core-v2/src/agent/skill/skillService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import { renderUserSlashSkillPrompt } from './prompt';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { Disposable } from '#/_base/di/lifecycle';
import { ErrorCodes, Error2 } from '#/errors';
import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCatalog/types';
import {
formatAmbiguousSkillMessage,
isUserActivatableSkillType,
type SkillDefinition,
} from '#/app/skillCatalog/types';
import { IAgentPromptService } from '#/agent/prompt/prompt';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import type { Turn } from '#/agent/loop/loop';
Expand All @@ -45,14 +49,21 @@ export class AgentSkillService extends Disposable implements IAgentSkillService

async activate(input: SkillActivationInput): Promise<Turn> {
await this.skillCatalog.ready;
const skill = this.skillCatalog.catalog.getSkill(input.name);
if (skill === undefined) {
const resolution = this.skillCatalog.catalog.resolveSkill(input.name);
if (resolution.kind === 'not-found') {
throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${input.name}" was not found`);
}
if (resolution.kind === 'ambiguous') {
throw new Error2(
ErrorCodes.SKILL_NOT_FOUND,
formatAmbiguousSkillMessage(input.name, resolution.candidates),
);
}
const { canonicalName, skill } = resolution;
if (!isUserActivatableSkillType(skill.metadata.type)) {
throw new Error2(
ErrorCodes.SKILL_TYPE_UNSUPPORTED,
`Skill "${skill.name}" cannot be activated by the user`,
`Skill "${canonicalName}" cannot be activated by the user`,
);
}

Expand All @@ -62,7 +73,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
{
type: 'text',
text: renderUserSlashSkillPrompt({
skillName: skill.name,
skillName: canonicalName,
skillArgs,
skillContent,
skillSource: skill.source,
Expand All @@ -75,7 +86,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
{
kind: 'skill_activation',
activationId: randomUUID(),
skillName: skill.name,
skillName: canonicalName,
trigger: 'user-slash',
skillType: skill.metadata.type,
skillPath: skill.path,
Expand Down
20 changes: 12 additions & 8 deletions packages/agent-core-v2/src/agent/tools/skill/skillTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { IAgentSkillService } from '#/agent/skill/skill';
import { renderModelToolSkillPrompt } from '#/agent/skill/prompt';
import type { ExecutableToolResult, ToolDeliveryMessage, ToolExecution } from '#/tool/toolContract';
import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution';
import { isInlineSkillType } from '#/app/skillCatalog/types';
import { formatAmbiguousSkillMessage, isInlineSkillType } from '#/app/skillCatalog/types';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { renderPrompt } from '#/_base/utils/render-prompt';
Expand Down Expand Up @@ -100,18 +100,22 @@ export async function executeModelSkill(
}

await catalog.ready;
const skill = catalog.catalog.getSkill(args.skill);
if (skill === undefined) {
const resolution = catalog.catalog.resolveSkill(args.skill);
if (resolution.kind === 'not-found') {
return errorResult(`Skill "${args.skill}" not found in the current skill listing.`);
}
if (resolution.kind === 'ambiguous') {
return errorResult(formatAmbiguousSkillMessage(args.skill, resolution.candidates));
}
const { canonicalName, skill } = resolution;
if (skill.metadata.disableModelInvocation === true) {
return errorResult(
`Skill "${args.skill}" can only be triggered by the user (model invocation is disabled).`,
`Skill "${canonicalName}" can only be triggered by the user (model invocation is disabled).`,
);
}
if (!isInlineSkillType(skill.metadata.type)) {
return errorResult(
`Skill "${skill.name}" is not an inline skill and cannot be invoked by the model in v1.`,
`Skill "${canonicalName}" is not an inline skill and cannot be invoked by the model in v1.`,
);
}

Expand All @@ -120,7 +124,7 @@ export async function executeModelSkill(
const origin: SkillActivationOrigin = {
kind: 'skill_activation',
activationId: randomUUID(),
skillName: skill.name,
skillName: canonicalName,
skillArgs: skillArgs.length > 0 ? skillArgs : undefined,
trigger,
skillType: skill.metadata.type,
Expand All @@ -134,7 +138,7 @@ export async function executeModelSkill(
{
type: 'text',
text: renderModelToolSkillPrompt({
skillName: skill.name,
skillName: canonicalName,
skillArgs,
skillContent,
skillSource: skill.source,
Expand All @@ -148,7 +152,7 @@ export async function executeModelSkill(
};
skillService.recordModelToolActivation(origin);
return {
output: `Skill "${skill.name}" loaded inline. Follow its instructions.`,
output: `Skill "${canonicalName}" loaded inline. Follow its instructions.`,
delivery: { kind: 'steer', message },
};
}
Expand Down
61 changes: 57 additions & 4 deletions packages/agent-core-v2/src/app/skillCatalog/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import type {
SkillCatalog,
SkillDefinition,
SkillMetadata,
SkillResolution,
SkillSource,
SkippedSkill,
} from './types';
import { isInlineSkillType, normalizeSkillName } from './types';
import { canonicalSkillName, isInlineSkillType, normalizeSkillName } from './types';

const LISTING_DESC_MAX = 250;

Expand Down Expand Up @@ -68,6 +69,42 @@ export class InMemorySkillCatalog implements SkillCatalog {
return this.byPluginAndName.get(pluginSkillKey(pluginId, name));
}

resolveSkill(name: string): SkillResolution {
const separator = name.indexOf(':');
if (separator > 0 && separator < name.length - 1) {
const pluginSkill = this.getPluginSkill(
name.slice(0, separator),
name.slice(separator + 1),
);
if (pluginSkill !== undefined) {
return {
kind: 'resolved',
skill: pluginSkill,
canonicalName: canonicalSkillName(pluginSkill),
};
}
}

const skill = this.getSkill(name);
if (skill === undefined) return { kind: 'not-found' };
if (skill.plugin === undefined) {
return { kind: 'resolved', skill, canonicalName: skill.name };
}

const candidates = this.pluginSkillsNamed(name);
if (candidates.length > 1) {
return {
kind: 'ambiguous',
candidates: candidates.map(canonicalSkillName),
};
Comment thread
Zavianx marked this conversation as resolved.
}
return {
kind: 'resolved',
skill,
canonicalName: canonicalSkillName(skill),
};
}

renderSkillPrompt(
skill: SkillDefinition,
rawArgs: string,
Expand All @@ -91,7 +128,10 @@ export class InMemorySkillCatalog implements SkillCatalog {
}

listSkills(): readonly SkillDefinition[] {
return [...this.byName.values()].toSorted((a, b) => a.name.localeCompare(b.name));
return [
...[...this.byName.values()].filter((skill) => skill.plugin === undefined),
...this.byPluginAndName.values(),
].toSorted((a, b) => canonicalSkillName(a).localeCompare(canonicalSkillName(b)));
}

listInvocableSkills(): readonly SkillDefinition[] {
Expand Down Expand Up @@ -136,6 +176,13 @@ export class InMemorySkillCatalog implements SkillCatalog {
this.byPluginAndName.set(key, skill);
}
}

private pluginSkillsNamed(name: string): readonly SkillDefinition[] {
const normalizedName = normalizeSkillName(name);
return [...this.byPluginAndName.values()]
.filter((skill) => normalizeSkillName(skill.name) === normalizedName)
.toSorted((a, b) => canonicalSkillName(a).localeCompare(canonicalSkillName(b)));
}
}

interface SkillExpandContext {
Expand Down Expand Up @@ -221,11 +268,17 @@ function renderGroupedSkills(
}

function formatFullSkill(skill: SkillDefinition): readonly string[] {
return [`- ${skill.name}`, ` - Path: ${skill.path}`, ` - Description: ${skill.description}`];
return [
`- ${canonicalSkillName(skill)}`,
` - Path: ${skill.path}`,
` - Description: ${skill.description}`,
];
}

function formatModelSkill(skill: SkillDefinition): readonly string[] {
const lines = [`- ${skill.name}: ${truncate(skill.description, LISTING_DESC_MAX)}`];
const lines = [
`- ${canonicalSkillName(skill)}: ${truncate(skill.description, LISTING_DESC_MAX)}`,
];
if (typeof skill.metadata.whenToUse === 'string' && skill.metadata.whenToUse.length > 0) {
lines.push(` When to use: ${skill.metadata.whenToUse}`);
}
Expand Down
24 changes: 24 additions & 0 deletions packages/agent-core-v2/src/app/skillCatalog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ export interface SkillPluginContext {
readonly instructions?: string;
}

export type SkillResolution =
| {
readonly kind: 'resolved';
readonly skill: SkillDefinition;
readonly canonicalName: string;
}
| { readonly kind: 'not-found' }
| {
readonly kind: 'ambiguous';
readonly candidates: readonly string[];
};

export interface SkippedSkill {
readonly path: string;
readonly type: string;
Expand All @@ -55,6 +67,7 @@ export interface SkippedSkill {
export interface SkillCatalog {
getSkill(name: string): SkillDefinition | undefined;
getPluginSkill(pluginId: string, name: string): SkillDefinition | undefined;
resolveSkill(name: string): SkillResolution;
renderSkillPrompt(
skill: SkillDefinition,
rawArgs: string,
Expand All @@ -71,6 +84,17 @@ export function normalizeSkillName(name: string): string {
return name.toLowerCase();
}

export function canonicalSkillName(skill: SkillDefinition): string {
return skill.plugin === undefined ? skill.name : `${skill.plugin.id}:${skill.name}`;
}

export function formatAmbiguousSkillMessage(
name: string,
candidates: readonly string[],
): string {
return `Skill "${name}" is ambiguous. Use one of these qualified names: ${candidates.map((candidate) => `"${candidate}"`).join(', ')}.`;
}

export function isInlineSkillType(type: string | undefined): boolean {
return type === undefined || type === 'prompt' || type === 'inline';
}
Expand Down
Loading