diff --git a/.changeset/slash-skill-system-reminder.md b/.changeset/slash-skill-system-reminder.md new file mode 100644 index 00000000..3db89a2c --- /dev/null +++ b/.changeset/slash-skill-system-reminder.md @@ -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. diff --git a/packages/agent-core/src/agent/context/notification-xml.ts b/packages/agent-core/src/agent/context/notification-xml.ts index 45eda702..c1da4f12 100644 --- a/packages/agent-core/src/agent/context/notification-xml.ts +++ b/packages/agent-core/src/agent/context/notification-xml.ts @@ -24,6 +24,8 @@ * look alike (`agent-...`) but live in different namespaces. */ +import { escapeXmlAttr } from '#/utils/xml-escape'; + export function renderNotificationXml(data: Record): string { const id = stringAttr(data['id'], 'unknown'); const category = stringAttr(data['category'], 'unknown'); @@ -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('&', '&').replaceAll('"', '"'); + return escapeXmlAttr(value); } /** Like `stringAttr` but returns `undefined` instead of a fallback so the 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 1f1705a4..16d41489 100644 --- a/packages/agent-core/src/agent/injection/plugin-session-start.ts +++ b/packages/agent-core/src/agent/injection/plugin-session-start.ts @@ -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 { @@ -43,11 +44,7 @@ function renderSessionStartBlock( skillContent: string, ): string { return ( - `\n${skillContent}\n` + `\n${skillContent}\n` ); } - -function escapeAttr(value: string): string { - return value.replaceAll('"', '"'); -} diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts index 7d698b7e..19d08af7 100644 --- a/packages/agent-core/src/agent/skill/index.ts +++ b/packages/agent-core/src/agent/skill/index.ts @@ -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 { @@ -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: + `\n` + + `\n` + + `${skillContent}\n` + + `\n` + + ``, + }, + ]; + this.recordActivation( { kind: 'skill_activation', @@ -34,12 +50,7 @@ export class SkillManager { skillSource: skill.source, skillArgs: input.args, }, - [ - { - type: 'text', - text: this.registry.renderSkillPrompt(skill, input.args ?? ''), - }, - ], + wrapped, ); } diff --git a/packages/agent-core/src/skill/parser.ts b/packages/agent-core/src/skill/parser.ts index cee0b953..23d281c9 100644 --- a/packages/agent-core/src/skill/parser.ts +++ b/packages/agent-core/src/skill/parser.ts @@ -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) { @@ -186,20 +187,20 @@ 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 @@ -207,7 +208,7 @@ export function expandSkillParameters( .replaceAll('${KIMI_SESSION_ID}', context.sessionId ?? ''); if (!hasArgumentPlaceholder && rawArgs.length > 0) { - return `${content}\n\nARGUMENTS: ${rawArgs}`; + return `${content}\n\nARGUMENTS: ${escapeXmlTags(rawArgs)}`; } return content; } diff --git a/packages/agent-core/src/skill/registry.ts b/packages/agent-core/src/skill/registry.ts index a03d36c4..44f37d84 100644 --- a/packages/agent-core/src/skill/registry.ts +++ b/packages/agent-core/src/skill/registry.ts @@ -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; @@ -98,7 +99,7 @@ export class SkillRegistry { const instructions = plugin.instructions; if (instructions === undefined || instructions.trim().length === 0) return content; return ( - `\n` + + `\n` + `${instructions}\n` + `\n\n${content}` ); @@ -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('&', '&').replaceAll('"', '"'); -} 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 db40cdfd..5631c658 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts @@ -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'; @@ -163,10 +164,3 @@ function skillOrigin( }; } -function escapeXml(input: string): string { - return input - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"'); -} diff --git a/packages/agent-core/src/utils/xml-escape.ts b/packages/agent-core/src/utils/xml-escape.ts new file mode 100644 index 00000000..8d803ca6 --- /dev/null +++ b/packages/agent-core/src/utils/xml-escape.ts @@ -0,0 +1,18 @@ +/** Escape XML content — escapes both tag and attribute boundary chars (& < > ") */ +export function escapeXml(input: string): string { + return input + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +/** Escape XML attribute value — only escapes attribute boundary chars (& "), not tag chars */ +export function escapeXmlAttr(input: string): string { + return input.replaceAll('&', '&').replaceAll('"', '"'); +} + +/** Escape tag delimiters only — prevents XML tag injection without corrupting Markdown (& " stay literal) */ +export function escapeXmlTags(input: string): string { + return input.replaceAll('<', '<').replaceAll('>', '>'); +} diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts index ac6be2b4..76366049 100644 --- a/packages/agent-core/test/harness/skill-session.test.ts +++ b/packages/agent-core/test/harness/skill-session.test.ts @@ -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 = + '\n\n' + + 'Review the requested file.\n\nARGUMENTS: src/app.ts\n\n'; expect(prompt).toMatchObject({ type: 'turn.prompt', input: [{ type: 'text', text: expectedPrompt }], @@ -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 = [ + '', + '', 'Target: src/app.ts', 'Mode: careful', 'Raw: "src/app.ts" careful', `Dir: ${skillDir}`, 'Session: ses_skill_template', + '', + '', ].join('\n'); expect(prompt).toMatchObject({ type: 'turn.prompt', @@ -354,7 +360,7 @@ describe('HarnessAPI session skills', () => { content: [ { type: 'text', - text: 'Review the requested file.\n\nARGUMENTS: src/app.ts', + text: '\n\nReview the requested file.\n\nARGUMENTS: src/app.ts\n\n', }, ], origin: { diff --git a/packages/agent-core/test/tools/skill-tool.test.ts b/packages/agent-core/test/tools/skill-tool.test.ts index aa8a0338..99ef0440 100644 --- a/packages/agent-core/test/tools/skill-tool.test.ts +++ b/packages/agent-core/test/tools/skill-tool.test.ts @@ -236,7 +236,7 @@ describe('SkillTool execution', () => { await execute(tool, { skill: 'a&b', args: '' }); expect(methods.recordSystemReminder.mock.calls[0]?.[0]).toContain( - '\nbody of a&b\n\nARGUMENTS: \n', + '\nbody of a&b\n\nARGUMENTS: <raw "value">\n', ); expect(methods.recordSkillActivation).toHaveBeenCalledTimes(1); }); diff --git a/packages/node-sdk/test/session-skills.test.ts b/packages/node-sdk/test/session-skills.test.ts index 6f785bb4..235bc756 100644 --- a/packages/node-sdk/test/session-skills.test.ts +++ b/packages/node-sdk/test/session-skills.test.ts @@ -178,7 +178,7 @@ describe('Session skills', () => { input: [ { type: 'text', - text: 'Review the requested file.\n\nARGUMENTS: src/app.ts', + text: '\n\nReview the requested file.\n\nARGUMENTS: src/app.ts\n\n', }, ], origin: {