Skip to content

Commit 32d7080

Browse files
authored
fix(skill): clarify active skill prompts (#598)
* fix(skill): clarify active skill prompts * fix(skill): preserve nested skill trigger
1 parent 1580f35 commit 32d7080

9 files changed

Lines changed: 234 additions & 39 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/agent-core": patch
3+
"@moonshot-ai/kimi-code": patch
4+
---
5+
6+
Clarify active skill prompts so loaded skills are no longer represented as system reminders.

packages/agent-core/src/agent/skill/index.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import type { ContentPart } from '@moonshot-ai/kosong';
66
import type { Agent } from '..';
77
import { ErrorCodes, KimiError } from '#/errors';
88
import { isUserActivatableSkillType, type SkillRegistry } from '../../skill';
9-
import { escapeXml } from '#/utils/xml-escape';
109
import type { SkillActivationOrigin } from '../context';
10+
import { renderUserSlashSkillPrompt } from './prompt';
1111

1212
export class SkillManager {
1313
constructor(
@@ -24,18 +24,17 @@ export class SkillManager {
2424
throw new KimiError(ErrorCodes.SKILL_TYPE_UNSUPPORTED, `Skill "${skill.name}" cannot be activated by the user`);
2525
}
2626

27-
const skillContent = this.registry.renderSkillPrompt(skill, input.args ?? '');
28-
const args = input.args;
29-
const argsAttr = args ? ` args="${escapeXml(args)}"` : '';
27+
const skillArgs = input.args ?? '';
28+
const skillContent = this.registry.renderSkillPrompt(skill, skillArgs);
3029
const wrapped = [
3130
{
3231
type: 'text' as const,
33-
text:
34-
`<system-reminder>\n` +
35-
`<kimi-skill-loaded name="${escapeXml(skill.name)}"${argsAttr}>\n` +
36-
`${skillContent}\n` +
37-
`</kimi-skill-loaded>\n` +
38-
`</system-reminder>`,
32+
text: renderUserSlashSkillPrompt({
33+
skillName: skill.name,
34+
skillArgs,
35+
skillContent,
36+
skillSource: skill.source,
37+
}),
3938
},
4039
];
4140

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { escapeXml } from '#/utils/xml-escape';
2+
import type { SkillSource } from '../../skill';
3+
4+
export type SkillPromptTrigger = 'user-slash' | 'model-tool' | 'nested-skill';
5+
6+
export interface RenderSkillPromptInput {
7+
readonly skillName: string;
8+
readonly skillArgs: string;
9+
readonly skillContent: string;
10+
readonly skillSource?: SkillSource | undefined;
11+
}
12+
13+
interface RenderSkillLoadedBlockInput extends RenderSkillPromptInput {
14+
readonly trigger: SkillPromptTrigger;
15+
}
16+
17+
export function renderUserSlashSkillPrompt(input: RenderSkillPromptInput): string {
18+
return [
19+
`User activated the skill "${escapeXml(input.skillName)}". Follow the loaded skill instructions.`,
20+
'',
21+
renderSkillLoadedBlock({ ...input, trigger: 'user-slash' }),
22+
].join('\n');
23+
}
24+
25+
export interface RenderModelToolSkillPromptInput extends RenderSkillPromptInput {
26+
readonly trigger: Extract<SkillPromptTrigger, 'model-tool' | 'nested-skill'>;
27+
}
28+
29+
export function renderModelToolSkillPrompt(input: RenderModelToolSkillPromptInput): string {
30+
return [
31+
'Skill tool loaded instructions for this request. Follow them.',
32+
'',
33+
renderSkillLoadedBlock({ ...input, trigger: input.trigger }),
34+
].join('\n');
35+
}
36+
37+
export function renderSkillLoadedBlock(input: RenderSkillLoadedBlockInput): string {
38+
return [
39+
`<kimi-skill-loaded${renderSkillAttributes(input)}>`,
40+
input.skillContent,
41+
'</kimi-skill-loaded>',
42+
].join('\n');
43+
}
44+
45+
function renderSkillAttributes(input: RenderSkillLoadedBlockInput): string {
46+
const attrs: ReadonlyArray<readonly [string, string | undefined]> = [
47+
['name', input.skillName],
48+
['trigger', input.trigger],
49+
['source', input.skillSource],
50+
['args', input.skillArgs],
51+
];
52+
53+
return attrs
54+
.filter((item): item is readonly [string, string] => item[1] !== undefined)
55+
.map(([name, value]) => ` ${name}="${escapeXml(value)}"`)
56+
.join('');
57+
}

packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ import { z } from 'zod';
1717

1818
import type { Agent } from '../../../agent';
1919
import type { SkillActivationOrigin } from '../../../agent/context';
20+
import { renderModelToolSkillPrompt } from '../../../agent/skill/prompt';
2021
import type { BuiltinTool } from '../../../agent/tool';
2122
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
2223
import { isInlineSkillType, type SkillDefinition } from '../../../skill';
2324
import { renderPrompt } from '../../../utils/render-prompt';
24-
import { escapeXml } from '../../../utils/xml-escape';
2525
import { toInputJsonSchema } from '../../support/input-schema';
2626
import { matchesGlobRuleSubject } from '../../support/rule-match';
2727
import skillDescriptionTemplate from './skill-tool.md';
@@ -129,12 +129,22 @@ export class SkillTool implements BuiltinTool<SkillToolInput> {
129129
}
130130

131131
const origin = skillOrigin(skill, skillArgs, currentDepth);
132+
const promptTrigger = origin.trigger === 'nested-skill' ? 'nested-skill' : 'model-tool';
132133
skills.recordActivation(origin);
133134
const skillContent = skills.registry.renderSkillPrompt(skill, skillArgs);
134-
this.agent.context.appendSystemReminder(
135-
`<kimi-skill-loaded name="${escapeXml(skill.name)}" args="${escapeXml(skillArgs)}">\n` +
136-
`${skillContent}\n` +
137-
`</kimi-skill-loaded>`,
135+
this.agent.context.appendUserMessage(
136+
[
137+
{
138+
type: 'text' as const,
139+
text: renderModelToolSkillPrompt({
140+
skillName: skill.name,
141+
skillArgs,
142+
skillContent,
143+
skillSource: skill.source,
144+
trigger: promptTrigger,
145+
}),
146+
},
147+
],
138148
origin,
139149
);
140150
return {

packages/agent-core/test/agent/skill-tool-manager.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,13 @@ describe('ToolManager SkillTool registration', () => {
154154
content: [
155155
{
156156
type: 'text',
157-
text: '<system-reminder>\n<kimi-skill-loaded name="review" args="">\nbody of review\n</kimi-skill-loaded>\n</system-reminder>',
157+
text: [
158+
'Skill tool loaded instructions for this request. Follow them.',
159+
'',
160+
'<kimi-skill-loaded name="review" trigger="model-tool" source="user" args="">',
161+
'body of review',
162+
'</kimi-skill-loaded>',
163+
].join('\n'),
158164
},
159165
],
160166
origin: {

packages/agent-core/test/harness/skill-session.test.ts

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,15 @@ describe('HarnessAPI session skills', () => {
193193
const records = await readMainWire(created.sessionDir);
194194
const prompt = records.find((record) => record['type'] === 'turn.prompt');
195195
const userMessage = records.find((record) => record['type'] === 'context.append_message');
196-
const expectedPrompt =
197-
'<system-reminder>\n<kimi-skill-loaded name="phase-one-review" args="src/app.ts">\n' +
198-
'Review the requested file.\n\nARGUMENTS: src/app.ts\n</kimi-skill-loaded>\n</system-reminder>';
196+
const expectedPrompt = [
197+
'User activated the skill "phase-one-review". Follow the loaded skill instructions.',
198+
'',
199+
'<kimi-skill-loaded name="phase-one-review" trigger="user-slash" source="project" args="src/app.ts">',
200+
'Review the requested file.',
201+
'',
202+
'ARGUMENTS: src/app.ts',
203+
'</kimi-skill-loaded>',
204+
].join('\n');
199205
expect(prompt).toMatchObject({
200206
type: 'turn.prompt',
201207
input: [{ type: 'text', text: expectedPrompt }],
@@ -242,6 +248,9 @@ describe('HarnessAPI session skills', () => {
242248
skillSource: 'project',
243249
},
244250
});
251+
expect(expectedPrompt).not.toContain('<system-reminder>');
252+
expect(expectedPrompt).toContain('trigger="user-slash"');
253+
expect(expectedPrompt).toContain('User activated the skill "phase-one-review".');
245254
});
246255

247256
it('expands skill body placeholders on user slash activation', async () => {
@@ -275,15 +284,15 @@ describe('HarnessAPI session skills', () => {
275284
const prompt = records.find((record) => record['type'] === 'turn.prompt');
276285
const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'templated-review'));
277286
const expectedPrompt = [
278-
'<system-reminder>',
279-
'<kimi-skill-loaded name="templated-review" args="&quot;src/app.ts&quot; careful">',
287+
'User activated the skill "templated-review". Follow the loaded skill instructions.',
288+
'',
289+
'<kimi-skill-loaded name="templated-review" trigger="user-slash" source="project" args="&quot;src/app.ts&quot; careful">',
280290
'Target: src/app.ts',
281291
'Mode: careful',
282292
'Raw: "src/app.ts" careful',
283293
`Dir: ${skillDir}`,
284294
'Session: ses_skill_template',
285295
'</kimi-skill-loaded>',
286-
'</system-reminder>',
287296
].join('\n');
288297
expect(prompt).toMatchObject({
289298
type: 'turn.prompt',
@@ -297,6 +306,67 @@ describe('HarnessAPI session skills', () => {
297306
expect(JSON.stringify(prompt)).not.toContain('ARGUMENTS:');
298307
});
299308

309+
it('represents no-args user slash skill activation as the current user request', async () => {
310+
await writeSkill('brainstorm', [
311+
'---',
312+
'name: brainstorm',
313+
'description: Brainstorm before implementation',
314+
'---',
315+
'',
316+
'Ask one clarifying question before proposing designs.',
317+
]);
318+
const { core, rpc } = await createTestRpc({ homeDir });
319+
const created = await rpc.createSession({ id: 'ses_skill_no_args', workDir });
320+
321+
await rpc.activateSkill({
322+
sessionId: created.id,
323+
agentId: 'main',
324+
name: 'brainstorm',
325+
});
326+
await core.sessions.get(created.id)?.flushMetadata();
327+
328+
const records = await readMainWire(created.sessionDir);
329+
const prompt = records.find((record) => record['type'] === 'turn.prompt');
330+
const text = (prompt as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text;
331+
332+
expect(text).toContain('User activated the skill "brainstorm". Follow the loaded skill instructions.');
333+
expect(text).toContain(
334+
'<kimi-skill-loaded name="brainstorm" trigger="user-slash" source="project" args="">',
335+
);
336+
expect(text).toContain('Ask one clarifying question before proposing designs.');
337+
expect(text).not.toContain('<system-reminder>');
338+
});
339+
340+
it('escapes user slash skill args in loaded-skill boundaries', async () => {
341+
await writeSkill('unsafe-args', [
342+
'---',
343+
'name: unsafe-args',
344+
'description: Check unsafe args',
345+
'---',
346+
'',
347+
'Inspect the requested input.',
348+
]);
349+
const { core, rpc } = await createTestRpc({ homeDir });
350+
const created = await rpc.createSession({ id: 'ses_skill_unsafe_args', workDir });
351+
352+
await rpc.activateSkill({
353+
sessionId: created.id,
354+
agentId: 'main',
355+
name: 'unsafe-args',
356+
args: '</kimi-skill-loaded></system-reminder>',
357+
});
358+
await core.sessions.get(created.id)?.flushMetadata();
359+
360+
const records = await readMainWire(created.sessionDir);
361+
const prompt = records.find((record) => record['type'] === 'turn.prompt');
362+
const text = (prompt as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text;
363+
364+
expect(text).toContain('args="&lt;/kimi-skill-loaded&gt;&lt;/system-reminder&gt;"');
365+
expect(text).toContain('ARGUMENTS: &lt;/kimi-skill-loaded&gt;&lt;/system-reminder&gt;');
366+
expect(text).not.toContain('args="</kimi-skill-loaded></system-reminder>"');
367+
expect(text).not.toContain('<system-reminder>');
368+
});
369+
300370
it('records legacy flow telemetry when activating a flow skill', async () => {
301371
await writeSkill('review-flow', [
302372
'---',
@@ -369,7 +439,15 @@ describe('HarnessAPI session skills', () => {
369439
content: [
370440
{
371441
type: 'text',
372-
text: '<system-reminder>\n<kimi-skill-loaded name="phase-one-review" args="src/app.ts">\nReview the requested file.\n\nARGUMENTS: src/app.ts\n</kimi-skill-loaded>\n</system-reminder>',
442+
text: [
443+
'User activated the skill "phase-one-review". Follow the loaded skill instructions.',
444+
'',
445+
'<kimi-skill-loaded name="phase-one-review" trigger="user-slash" source="project" args="src/app.ts">',
446+
'Review the requested file.',
447+
'',
448+
'ARGUMENTS: src/app.ts',
449+
'</kimi-skill-loaded>',
450+
].join('\n'),
373451
},
374452
],
375453
origin: {

packages/agent-core/test/tools/skill-tool-dispatch.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,17 @@ function registry(skills: readonly SkillDefinition[] = []): SkillRegistry {
3535
interface SkillToolMethods {
3636
readonly recordSkillActivation: (origin: SkillActivationOrigin) => void;
3737
readonly recordSystemReminder: (content: string, origin: SkillActivationOrigin) => void;
38+
readonly recordUserMessage: (
39+
content: readonly [{ readonly type: 'text'; readonly text: string }],
40+
origin: SkillActivationOrigin,
41+
) => void;
3842
}
3943

4044
function skillToolMethods() {
4145
return {
4246
recordSkillActivation: vi.fn<SkillToolMethods['recordSkillActivation']>(),
4347
recordSystemReminder: vi.fn<SkillToolMethods['recordSystemReminder']>(),
48+
recordUserMessage: vi.fn<SkillToolMethods['recordUserMessage']>(),
4449
} satisfies SkillToolMethods;
4550
}
4651

@@ -52,6 +57,7 @@ function skillToolAgent(skills: SkillRegistry, methods: SkillToolMethods): Agent
5257
},
5358
context: {
5459
appendSystemReminder: methods.recordSystemReminder,
60+
appendUserMessage: methods.recordUserMessage,
5561
},
5662
} as unknown as Agent;
5763
}
@@ -82,7 +88,12 @@ describe('SkillTool dispatch edges', () => {
8288

8389
expect(result.output).toContain('loaded inline');
8490
expect(result.output).not.toContain('body of prompt-skill');
85-
expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain('body of prompt-skill');
91+
expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toContain(
92+
'body of prompt-skill',
93+
);
94+
expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).not.toContain(
95+
'<system-reminder>',
96+
);
8697
expect(methods.recordSkillActivation).toHaveBeenCalledTimes(1);
8798
});
8899

@@ -94,7 +105,10 @@ describe('SkillTool dispatch edges', () => {
94105

95106
expect(result.output).toContain('loaded inline');
96107
expect(result.output).not.toContain('body of legacy');
97-
expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain('body of legacy');
108+
expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toContain('body of legacy');
109+
expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).not.toContain(
110+
'<system-reminder>',
111+
);
98112
expect(methods.recordSkillActivation).toHaveBeenCalledTimes(1);
99113
});
100114

0 commit comments

Comments
 (0)