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
6 changes: 6 additions & 0 deletions .changeset/slash-skill-system-reminder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---

Fix slash-activated skills not being recognized by the model due to missing system reminder wrapper.
7 changes: 3 additions & 4 deletions packages/agent-core/src/agent/context/notification-xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
* look alike (`agent-...`) but live in different namespaces.
*/

import { escapeXmlAttr } from '#/utils/xml-escape';

export function renderNotificationXml(data: Record<string, unknown>): string {
const id = stringAttr(data['id'], 'unknown');
const category = stringAttr(data['category'], 'unknown');
Expand Down Expand Up @@ -74,10 +76,7 @@ function truncateTailOutput(raw: string, maxLines: number, maxChars: number): st

function stringAttr(value: unknown, fallback: string): string {
if (typeof value !== 'string' || value.length === 0) return fallback;
// Attribute boundary safety: escape `&` and `"`. Body-text `<` / `>`
// stay untouched — the injection target is an LLM-visible transcript
// where double-escaping would be noisier than literal punctuation.
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
return escapeXmlAttr(value);
}

/** Like `stringAttr` but returns `undefined` instead of a fallback so the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { EnabledPluginSessionStart } from '../../plugin/types';
import type { SkillDefinition } from '../../skill';
import { escapeXmlAttr } from '../../utils/xml-escape';
import { DynamicInjector } from './injector';

export class PluginSessionStartInjector extends DynamicInjector {
Expand Down Expand Up @@ -43,11 +44,7 @@ function renderSessionStartBlock(
skillContent: string,
): string {
return (
`<plugin_session_start plugin="${escapeAttr(sessionStart.pluginId)}" ` +
`skill="${escapeAttr(skill.name)}">\n${skillContent}\n</plugin_session_start>`
`<plugin_session_start plugin="${escapeXmlAttr(sessionStart.pluginId)}" ` +
`skill="${escapeXmlAttr(skill.name)}">\n${skillContent}\n</plugin_session_start>`
);
}

function escapeAttr(value: string): string {
return value.replaceAll('"', '&quot;');
}
23 changes: 17 additions & 6 deletions packages/agent-core/src/agent/skill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ContentPart } from '@moonshot-ai/kosong';
import type { Agent } from '..';
import { ErrorCodes, KimiError } from '#/errors';
import { isUserActivatableSkillType, type SkillRegistry } from '../../skill';
import { escapeXml } from '#/utils/xml-escape';
import type { SkillActivationOrigin } from '../context';

export class SkillManager {
Expand All @@ -23,6 +24,21 @@ export class SkillManager {
throw new KimiError(ErrorCodes.SKILL_TYPE_UNSUPPORTED, `Skill "${skill.name}" cannot be activated by the user`);
}

const skillContent = this.registry.renderSkillPrompt(skill, input.args ?? '');
const args = input.args;
const argsAttr = args ? ` args="${escapeXml(args)}"` : '';
const wrapped = [
{
type: 'text' as const,
text:
`<system-reminder>\n` +
`<kimi-skill-loaded name="${escapeXml(skill.name)}"${argsAttr}>\n` +
`${skillContent}\n` +
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep user slash args out of system reminders

When a slash command includes args and the skill body does not consume placeholders, renderSkillPrompt() appends ARGUMENTS: ${rawArgs}; this line then places that raw user-controlled text inside the new <system-reminder> wrapper. For inputs containing XML delimiters such as </kimi-skill-loaded></system-reminder> (or directive-like text), the args can terminate or corrupt the wrapper and be treated as authoritative reminder content rather than data, defeating the boundary this change is trying to add. Consider escaping/sandboxing rendered arguments or keeping them outside the reminder.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it ,i will fix it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape placeholder args before wrapping skill content

When a slash-activated skill body contains $ARGUMENTS, $0, or named argument placeholders, renderSkillPrompt() substitutes the raw user args and this line embeds that result directly inside the new <system-reminder> / <kimi-skill-loaded> wrapper. An argument like </kimi-skill-loaded></system-reminder> can still terminate the wrapper for placeholder-consuming skills, even though the no-placeholder ARGUMENTS: path is now escaped. This is fresh evidence beyond the earlier no-placeholder finding: expandSkillParameters() still returns raw placeholder substitutions before this wrapping.

Useful? React with 👍 / 👎.

`</kimi-skill-loaded>\n` +
`</system-reminder>`,
},
];

this.recordActivation(
{
kind: 'skill_activation',
Expand All @@ -34,12 +50,7 @@ export class SkillManager {
skillSource: skill.source,
skillArgs: input.args,
},
[
{
type: 'text',
text: this.registry.renderSkillPrompt(skill, input.args ?? ''),
},
],
wrapped,
);
}

Expand Down
11 changes: 6 additions & 5 deletions packages/agent-core/src/skill/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import regexpEscape from 'regexp.escape';

import type { SkillDefinition, SkillMetadata, SkillSource } from './types';
import { isSupportedSkillType } from './types';
import { escapeXmlTags } from '../utils/xml-escape';

export class FrontmatterError extends Error {
constructor(message: string, cause?: unknown) {
Expand Down Expand Up @@ -186,28 +187,28 @@ export function expandSkillParameters(
const escaped = regexpEscape(name);
content = content.replaceAll(
new RegExp(`\\$${escaped}(?![\\[\\w])`, 'g'),
tokens[index] ?? '',
escapeXmlTags(tokens[index] ?? ''),
);
}

content = content
.replaceAll(/\$ARGUMENTS\[(\d+)\]/g, (_match, indexText: string) => {
const index = Number.parseInt(indexText, 10);
return tokens[index] ?? '';
return escapeXmlTags(tokens[index] ?? '');
})
.replaceAll(/\$(\d+)(?!\w)/g, (_match, indexText: string) => {
const index = Number.parseInt(indexText, 10);
return tokens[index] ?? '';
return escapeXmlTags(tokens[index] ?? '');
})
.replaceAll('$ARGUMENTS', rawArgs);
.replaceAll('$ARGUMENTS', escapeXmlTags(rawArgs));

const hasArgumentPlaceholder = content !== body;
content = content
.replaceAll('${KIMI_SKILL_DIR}', context.skillDir)
.replaceAll('${KIMI_SESSION_ID}', context.sessionId ?? '');

if (!hasArgumentPlaceholder && rawArgs.length > 0) {
return `${content}\n\nARGUMENTS: ${rawArgs}`;
return `${content}\n\nARGUMENTS: ${escapeXmlTags(rawArgs)}`;
}
return content;
}
Expand Down
7 changes: 2 additions & 5 deletions packages/agent-core/src/skill/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { escapeXmlAttr } from '../utils/xml-escape';

const LISTING_DESC_MAX = 250;

Expand Down Expand Up @@ -98,7 +99,7 @@ export class SkillRegistry {
const instructions = plugin.instructions;
if (instructions === undefined || instructions.trim().length === 0) return content;
return (
`<kimi-plugin-instructions plugin="${escapeAttr(plugin.id)}">\n` +
`<kimi-plugin-instructions plugin="${escapeXmlAttr(plugin.id)}">\n` +
`${instructions}\n` +
`</kimi-plugin-instructions>\n\n${content}`
);
Expand Down Expand Up @@ -181,7 +182,3 @@ function formatModelSkill(skill: SkillDefinition): readonly string[] {
function truncate(value: string, max: number): string {
return value.length > max ? value.slice(0, max) : value;
}

function escapeAttr(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { BuiltinTool } from '../../../agent/tool';
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
import { isInlineSkillType, type SkillDefinition } from '../../../skill';
import { renderPrompt } from '../../../utils/render-prompt';
import { escapeXml } from '../../../utils/xml-escape';
import { toInputJsonSchema } from '../../support/input-schema';
import { matchesGlobRuleSubject } from '../../support/rule-match';
import skillDescriptionTemplate from './skill-tool.md';
Expand Down Expand Up @@ -163,10 +164,3 @@ function skillOrigin(
};
}

function escapeXml(input: string): string {
return input
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
18 changes: 18 additions & 0 deletions packages/agent-core/src/utils/xml-escape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Escape XML content — escapes both tag and attribute boundary chars (& < > ") */
export function escapeXml(input: string): string {
return input
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}

/** Escape XML attribute value — only escapes attribute boundary chars (& "), not tag chars */
export function escapeXmlAttr(input: string): string {
return input.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
}

/** Escape tag delimiters only — prevents XML tag injection without corrupting Markdown (& " stay literal) */
export function escapeXmlTags(input: string): string {
return input.replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
10 changes: 8 additions & 2 deletions packages/agent-core/test/harness/skill-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ describe('HarnessAPI session skills', () => {
const records = await readMainWire(created.sessionDir);
const prompt = records.find((record) => record['type'] === 'turn.prompt');
const userMessage = records.find((record) => record['type'] === 'context.append_message');
const expectedPrompt = 'Review the requested file.\n\nARGUMENTS: src/app.ts';
const expectedPrompt =
'<system-reminder>\n<kimi-skill-loaded name="phase-one-review" args="src/app.ts">\n' +
'Review the requested file.\n\nARGUMENTS: src/app.ts\n</kimi-skill-loaded>\n</system-reminder>';
expect(prompt).toMatchObject({
type: 'turn.prompt',
input: [{ type: 'text', text: expectedPrompt }],
Expand Down Expand Up @@ -264,11 +266,15 @@ describe('HarnessAPI session skills', () => {
const prompt = records.find((record) => record['type'] === 'turn.prompt');
const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'templated-review'));
const expectedPrompt = [
'<system-reminder>',
'<kimi-skill-loaded name="templated-review" args="&quot;src/app.ts&quot; careful">',
'Target: src/app.ts',
'Mode: careful',
'Raw: "src/app.ts" careful',
`Dir: ${skillDir}`,
'Session: ses_skill_template',
'</kimi-skill-loaded>',
'</system-reminder>',
].join('\n');
expect(prompt).toMatchObject({
type: 'turn.prompt',
Expand Down Expand Up @@ -354,7 +360,7 @@ describe('HarnessAPI session skills', () => {
content: [
{
type: 'text',
text: 'Review the requested file.\n\nARGUMENTS: src/app.ts',
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>',
},
],
origin: {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/test/tools/skill-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ describe('SkillTool execution', () => {
await execute(tool, { skill: 'a&b', args: '<raw "value">' });

expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain(
'<kimi-skill-loaded name="a&amp;b" args="&lt;raw &quot;value&quot;&gt;">\nbody of a&b\n\nARGUMENTS: <raw "value">\n</kimi-skill-loaded>',
'<kimi-skill-loaded name="a&amp;b" args="&lt;raw &quot;value&quot;&gt;">\nbody of a&b\n\nARGUMENTS: &lt;raw "value"&gt;\n</kimi-skill-loaded>',
);
expect(methods.recordSkillActivation).toHaveBeenCalledTimes(1);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/node-sdk/test/session-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ describe('Session skills', () => {
input: [
{
type: 'text',
text: 'Review the requested file.\n\nARGUMENTS: src/app.ts',
text: '<system-reminder>\n<kimi-skill-loaded name="review" args="src/app.ts">\nReview the requested file.\n\nARGUMENTS: src/app.ts\n</kimi-skill-loaded>\n</system-reminder>',
},
],
origin: {
Expand Down