From 4578f05f44101f24d45c6452e2a6993cbb52e331 Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:29:00 +0800 Subject: [PATCH] fix: surface skill directory in the loaded-skill context block (#785) --- .changeset/skill-dir-in-context.md | 6 ++ packages/agent-core/src/agent/skill/index.ts | 1 + packages/agent-core/src/agent/skill/prompt.ts | 9 +++ .../tools/builtin/collaboration/skill-tool.ts | 1 + .../test/agent/skill-prompt.test.ts | 55 ++++++++++++++++ .../test/agent/skill-tool-manager.test.ts | 2 +- .../test/harness/skill-session.test.ts | 64 +++++++++++++++++-- .../agent-core/test/tools/skill-tool.test.ts | 10 +-- packages/node-sdk/test/session-skills.test.ts | 5 +- 9 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 .changeset/skill-dir-in-context.md create mode 100644 packages/agent-core/test/agent/skill-prompt.test.ts diff --git a/.changeset/skill-dir-in-context.md b/.changeset/skill-dir-in-context.md new file mode 100644 index 000000000..3f2fa2155 --- /dev/null +++ b/.changeset/skill-dir-in-context.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Include the skill's directory on the loaded-skill context block so the agent can locate a skill's bundled resources (scripts, templates) after it is invoked. diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts index 37b10f677..684122fb1 100644 --- a/packages/agent-core/src/agent/skill/index.ts +++ b/packages/agent-core/src/agent/skill/index.ts @@ -37,6 +37,7 @@ export class SkillManager { skillArgs, skillContent, skillSource: skill.source, + skillDir: skill.dir, }), }, ]; diff --git a/packages/agent-core/src/agent/skill/prompt.ts b/packages/agent-core/src/agent/skill/prompt.ts index 2ea21076b..54968b7ab 100644 --- a/packages/agent-core/src/agent/skill/prompt.ts +++ b/packages/agent-core/src/agent/skill/prompt.ts @@ -8,6 +8,14 @@ export interface RenderSkillPromptInput { readonly skillArgs: string; readonly skillContent: string; readonly skillSource?: SkillSource | undefined; + /** + * Absolute directory containing the skill's SKILL.md and any bundled + * resources (scripts, templates, data files). Surfaced on the loaded + * block so the agent can locate those resources with relative paths — + * without it, a skill that ships helper scripts is unusable unless the + * author manually embeds `${KIMI_SKILL_DIR}` in the body. + */ + readonly skillDir?: string | undefined; } interface RenderSkillLoadedBlockInput extends RenderSkillPromptInput { @@ -47,6 +55,7 @@ function renderSkillAttributes(input: RenderSkillLoadedBlockInput): string { ['name', input.skillName], ['trigger', input.trigger], ['source', input.skillSource], + ['dir', input.skillDir], ['args', input.skillArgs], ]; 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 d437e78fa..2ee932dd0 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts @@ -141,6 +141,7 @@ export class SkillTool implements BuiltinTool { skillArgs, skillContent, skillSource: skill.source, + skillDir: skill.dir, trigger: promptTrigger, }), }, diff --git a/packages/agent-core/test/agent/skill-prompt.test.ts b/packages/agent-core/test/agent/skill-prompt.test.ts new file mode 100644 index 000000000..fa628432f --- /dev/null +++ b/packages/agent-core/test/agent/skill-prompt.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { + renderModelToolSkillPrompt, + renderUserSlashSkillPrompt, +} from '../../src/agent/skill/prompt'; + +/** + * Regression coverage for the skill directory being surfaced on the + * `` block. Without `dir`, an agent that loads a skill + * cannot locate the skill's bundled resources (scripts, templates) by + * relative path — the bug this guards against. + */ +describe('renderSkillLoadedBlock skill directory', () => { + const base = { + skillName: 'review', + skillArgs: '', + skillContent: 'body', + skillSource: 'user' as const, + skillDir: '/home/user/.kimi-code/skills/review', + }; + + it('includes the skill directory for model-tool activations', () => { + const text = renderModelToolSkillPrompt({ ...base, trigger: 'model-tool' }); + expect(text).toContain('dir="/home/user/.kimi-code/skills/review"'); + }); + + it('includes the skill directory for nested-skill activations', () => { + const text = renderModelToolSkillPrompt({ ...base, trigger: 'nested-skill' }); + expect(text).toContain('dir="/home/user/.kimi-code/skills/review"'); + }); + + it('includes the skill directory for user-slash activations', () => { + const text = renderUserSlashSkillPrompt(base); + expect(text).toContain('dir="/home/user/.kimi-code/skills/review"'); + }); + + it('XML-escapes the skill directory', () => { + const text = renderUserSlashSkillPrompt({ + ...base, + skillDir: '/skills/a&b/"weird"/', + }); + expect(text).toContain('dir="/skills/a&b/"weird"/<dir>"'); + expect(text).not.toContain('dir="/skills/a&b/"weird"/"'); + }); + + it('omits the dir attribute when no directory is supplied', () => { + const { skillDir: _omit, ...withoutDir } = base; + const text = renderUserSlashSkillPrompt(withoutDir); + expect(text).not.toContain('dir='); + // Other attributes still render so the block is well-formed. + expect(text).toContain('name="review"'); + expect(text).toContain('source="user"'); + }); +}); 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 12ba9bb1d..51b42a74e 100644 --- a/packages/agent-core/test/agent/skill-tool-manager.test.ts +++ b/packages/agent-core/test/agent/skill-tool-manager.test.ts @@ -177,7 +177,7 @@ describe('ToolManager SkillTool registration', () => { text: [ 'Skill tool loaded instructions for this request. Follow them.', '', - '', + '', 'body of review', '', ].join('\n'), diff --git a/packages/agent-core/test/harness/skill-session.test.ts b/packages/agent-core/test/harness/skill-session.test.ts index e51e318f7..38e1f4236 100644 --- a/packages/agent-core/test/harness/skill-session.test.ts +++ b/packages/agent-core/test/harness/skill-session.test.ts @@ -193,10 +193,11 @@ 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 skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'phase-one-review')); const expectedPrompt = [ 'User activated the skill "phase-one-review". Follow the loaded skill instructions.', '', - '', + ``, 'Review the requested file.', '', 'ARGUMENTS: src/app.ts', @@ -286,7 +287,7 @@ describe('HarnessAPI session skills', () => { const expectedPrompt = [ 'User activated the skill "templated-review". Follow the loaded skill instructions.', '', - '', + ``, 'Target: src/app.ts', 'Mode: careful', 'Raw: "src/app.ts" careful', @@ -329,9 +330,10 @@ describe('HarnessAPI session skills', () => { const prompt = records.find((record) => record['type'] === 'turn.prompt'); const text = (prompt as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text; + const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'brainstorm')); expect(text).toContain('User activated the skill "brainstorm". Follow the loaded skill instructions.'); expect(text).toContain( - '', + ``, ); expect(text).toContain('Ask one clarifying question before proposing designs.'); expect(text).not.toContain(''); @@ -432,6 +434,7 @@ describe('HarnessAPI session skills', () => { const resumed = await second.rpc.resumeSession({ sessionId: created.id }); expect(second.events.some((event) => event.type === 'skill.activated')).toBe(false); + const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'phase-one-review')); const context = await second.rpc.getContext({ sessionId: created.id, agentId: 'main' }); expect(context.history).toMatchObject([ { @@ -442,7 +445,7 @@ describe('HarnessAPI session skills', () => { text: [ 'User activated the skill "phase-one-review". Follow the loaded skill instructions.', '', - '', + ``, 'Review the requested file.', '', 'ARGUMENTS: src/app.ts', @@ -479,6 +482,59 @@ describe('HarnessAPI session skills', () => { ); }); + it('keeps the skill directory in the resumed conversation context so bundled resources stay locatable', async () => { + // A skill that ships a helper script but does NOT embed ${KIMI_SKILL_DIR} + // in its body. The only way the agent can learn where the script lives is + // the `dir` attribute on the loaded block — and it must survive a resume. + await writeSkill('bundled-tool', [ + '---', + 'name: bundled-tool', + 'description: A skill with a bundled script', + '---', + '', + 'Run the bundled helper script to do the work.', + ]); + const scriptDir = join(workDir, '.kimi-code', 'skills', 'bundled-tool', 'scripts'); + await mkdir(scriptDir, { recursive: true }); + await writeFile(join(scriptDir, 'run.sh'), '#!/bin/sh\necho hi\n'); + + const first = await createTestRpc(); + const created = await first.rpc.createSession({ id: 'ses_skill_resource_resume', workDir }); + await first.rpc.activateSkill({ + sessionId: created.id, + agentId: 'main', + name: 'bundled-tool', + }); + await waitForEvent(first.events, (event) => event.type === 'skill.activated'); + await first.core.sessions.get(created.id)?.flushMetadata(); + + // Resume in a completely fresh runtime — nothing in memory, the context is + // rebuilt from disk exactly as the model would see it on the next turn. + const second = await createTestRpc(); + await second.rpc.resumeSession({ sessionId: created.id }); + const context = await second.rpc.getContext({ sessionId: created.id, agentId: 'main' }); + + const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'bundled-tool')); + const skillMessage = context.history.find( + (entry) => + entry.origin?.kind === 'skill_activation' && + (entry.origin as { skillName?: string }).skillName === 'bundled-tool', + ); + expect(skillMessage).toBeDefined(); + const text = (skillMessage?.content?.[0] as { text?: string } | undefined)?.text ?? ''; + + // The directory is present in the resumed context... + expect(text).toContain(`dir="${skillDir}"`); + // ...and it is the directory that actually holds the bundled script, so an + // agent reading the context can resolve the resource by relative path. + expect(join(skillDir, 'scripts', 'run.sh')).toBe( + await realpath(join(scriptDir, 'run.sh')), + ); + // Guard the regression: the path is surfaced by the wrapper, not because + // the skill body happened to mention it. + expect(text).toContain('Run the bundled helper script to do the work.'); + }); + it('registers builtin mcp-config skill, hides it from the model, and activates it via slash', async () => { const { core, events, rpc } = await createTestRpc(); const created = await rpc.createSession({ id: 'ses_skill_builtin', workDir }); diff --git a/packages/agent-core/test/tools/skill-tool.test.ts b/packages/agent-core/test/tools/skill-tool.test.ts index 5be88fa37..be9ae88f1 100644 --- a/packages/agent-core/test/tools/skill-tool.test.ts +++ b/packages/agent-core/test/tools/skill-tool.test.ts @@ -147,7 +147,7 @@ describe('SkillTool execution', () => { expect(methods.recordUserMessage).toHaveBeenCalledTimes(1); expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toBe( 'Skill tool loaded instructions for this request. Follow them.\n\n' + - '\nbody of commit\n\nARGUMENTS: message text\n', + '\nbody of commit\n\nARGUMENTS: message text\n', ); expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).not.toContain( '', @@ -174,7 +174,7 @@ describe('SkillTool execution', () => { expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toBe( 'Skill tool loaded instructions for this request. Follow them.\n\n' + - '\n' + + '\n' + '\n' + 'Use AskUserQuestion for clarifying questions.\n' + '\n\nbrainstorm body\n' + @@ -199,7 +199,7 @@ describe('SkillTool execution', () => { expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toBe( 'Skill tool loaded instructions for this request. Follow them.\n\n' + - '\nFlag: -m\nCommit message: fix login\nRaw: -m "fix login"\n', + '\nFlag: -m\nCommit message: fix login\nRaw: -m "fix login"\n', ); expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).not.toContain('ARGUMENTS:'); }); @@ -217,7 +217,7 @@ describe('SkillTool execution', () => { expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toBe( 'Skill tool loaded instructions for this request. Follow them.\n\n' + - '\nSession: ses_model_skill\n', + '\nSession: ses_model_skill\n', ); }); @@ -251,7 +251,7 @@ describe('SkillTool execution', () => { expect(methods.recordUserMessage.mock.calls[0]?.[0][0]?.text).toBe( 'Skill tool loaded instructions for this request. Follow them.\n\n' + - '\nbody of a&b\n\nARGUMENTS: <raw "value">\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 392424716..cf8412f05 100644 --- a/packages/node-sdk/test/session-skills.test.ts +++ b/packages/node-sdk/test/session-skills.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type * as KosongModule from '@moonshot-ai/kosong'; @@ -172,6 +172,7 @@ describe('Session skills', () => { expect(state['isCustomTitle']).toBe(false); expect(state['lastPrompt']).toBe('/review src/app.ts'); + const skillDir = await realpath(join(workDir, '.kimi-code', 'skills', 'review')); await expect( waitForAgentWireEvent( homeDir, @@ -187,7 +188,7 @@ describe('Session skills', () => { text: [ 'User activated the skill "review". Follow the loaded skill instructions.', '', - '', + ``, 'Review the requested file.', '', 'ARGUMENTS: src/app.ts',