Skip to content

Commit 07cfe21

Browse files
committed
feat: add support for implicit invocation control in skills
1 parent 7dba513 commit 07cfe21

4 files changed

Lines changed: 127 additions & 3 deletions

File tree

src/prompt.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as fs from "fs";
33
import * as os from "os";
44
import * as path from "path";
55
import ejs from "ejs";
6+
import matter from "gray-matter";
67
import { fileURLToPath } from "url";
78
import type { SessionMessage } from "./session";
89
import { findGitBashPath, resolveShellPath } from "./common/shell-utils";
@@ -184,11 +185,27 @@ export function buildSkillDocumentsPrompt(skills: SkillPromptDocument[]): string
184185
function renderSkillDocumentBlock(skill: SkillPromptDocument): string {
185186
const pathAttribute = skill.path ? ` path="${escapeXml(skill.path)}"` : "";
186187
const resources = renderSkillResources(skill.skillFilePath);
188+
const content = stripSkillPromptMetadata(skill.content);
187189
return `<${skill.name}-skill${pathAttribute}>
188-
${skill.content}${resources}
190+
${content}${resources}
189191
</${skill.name}-skill>`;
190192
}
191193

194+
function stripSkillPromptMetadata(content: string): string {
195+
try {
196+
const parsed = matter(content);
197+
if (!Object.prototype.hasOwnProperty.call(parsed.data, "metadata")) {
198+
return content;
199+
}
200+
201+
const frontmatter = { ...parsed.data };
202+
delete frontmatter.metadata;
203+
return matter.stringify(parsed.content, frontmatter);
204+
} catch {
205+
return content;
206+
}
207+
}
208+
192209
function renderSkillResources(skillFilePath?: string): string {
193210
if (!skillFilePath) {
194211
return "";

src/session.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ export type SkillInfo = {
289289
path: string;
290290
description: string;
291291
isLoaded?: boolean;
292+
allowImplicitInvocation?: boolean;
292293
};
293294

294295
type SessionManagerOptions = {
@@ -732,13 +733,14 @@ Response in JSON format:
732733
If none of the available skills match, respond with an empty array, i.e. \`{"skillNames": []}\`.\n
733734
`;
734735
const simpleSkills = skills
735-
.filter((x) => !x.isLoaded)
736+
.filter((x) => !x.isLoaded && x.allowImplicitInvocation !== false)
736737
.map((x) => {
737738
return { name: x.name, description: x.description };
738739
});
739740
if (simpleSkills.length === 0) {
740741
return [];
741742
}
743+
const candidateSkillNames = new Set(simpleSkills.map((skill) => skill.name));
742744

743745
const { client, model, baseURL, debugLogEnabled } = this.createOpenAIClient();
744746
if (!client) {
@@ -787,7 +789,10 @@ ${agentInstructions}
787789

788790
const parsed = JSON.parse(content);
789791
if (parsed && Array.isArray(parsed.skillNames)) {
790-
return parsed.skillNames;
792+
return parsed.skillNames.filter(
793+
(skillName: unknown): skillName is string =>
794+
typeof skillName === "string" && candidateSkillNames.has(skillName)
795+
);
791796
}
792797

793798
return [];
@@ -938,13 +943,22 @@ ${agentInstructions}
938943
try {
939944
const skillMd = fs.readFileSync(skillPath, "utf8");
940945
const parsed = matter(skillMd);
946+
const metadata = parsed.data.metadata;
947+
const allowImplicitInvocation =
948+
metadata &&
949+
typeof metadata === "object" &&
950+
!Array.isArray(metadata) &&
951+
(metadata as Record<string, unknown>)["allow-implicit-invocation"] === false
952+
? false
953+
: undefined;
941954
return {
942955
name:
943956
typeof parsed.data.name === "string" && parsed.data.name.trim()
944957
? parsed.data.name.trim()
945958
: fallbackSkill.name,
946959
path: displayPath,
947960
description: typeof parsed.data.description === "string" ? parsed.data.description.trim() : "",
961+
allowImplicitInvocation,
948962
};
949963
} catch {
950964
return fallbackSkill;

src/tests/prompt.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,27 @@ test("getDefaultSkillPrompt loads the default skill template", () => {
8888
assert.equal(prompt.includes('path="templates/skills/'), false);
8989
});
9090

91+
test("buildSkillDocumentsPrompt excludes SKILL.md frontmatter metadata", () => {
92+
const prompt = buildSkillDocumentsPrompt([
93+
{
94+
name: "example",
95+
content:
96+
"---\nname: example\ndescription: Example skill\nlicense: MIT\ncompatibility: Node.js\nallowed-tools: Read Bash\nmetadata:\n author: test\n allow-implicit-invocation: false\n---\n# Example Skill\n\nUse these instructions.\n",
97+
},
98+
]);
99+
100+
assert.equal(prompt.includes("name: example"), true);
101+
assert.equal(prompt.includes("description: Example skill"), true);
102+
assert.equal(prompt.includes("license: MIT"), true);
103+
assert.equal(prompt.includes("compatibility: Node.js"), true);
104+
assert.equal(prompt.includes("allowed-tools: Read Bash"), true);
105+
assert.equal(prompt.includes("# Example Skill"), true);
106+
assert.equal(prompt.includes("Use these instructions."), true);
107+
assert.equal(prompt.includes("metadata:"), false);
108+
assert.equal(prompt.includes("author: test"), false);
109+
assert.equal(prompt.includes("allow-implicit-invocation"), false);
110+
});
111+
91112
test("buildSkillDocumentsPrompt lists skill resources", () => {
92113
const skillDir = createTempDir("deepcode-skill-resources-");
93114
fs.mkdirSync(path.join(skillDir, "scripts"), { recursive: true });

src/tests/session.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,78 @@ test("SessionManager excludes disabled skills by resolved skill name", async ()
644644
assert.equal(skills[0]?.path, "./.deepcode/skills/enabled-skill/SKILL.md");
645645
});
646646

647+
test("SessionManager keeps implicit opt-out skills available for manual invocation", async () => {
648+
const workspace = createTempDir("deepcode-manual-only-skill-workspace-");
649+
const home = createTempDir("deepcode-manual-only-skill-home-");
650+
setHomeDir(home);
651+
652+
const skillDir = path.join(workspace, ".agents", "skills", "manual-only");
653+
fs.mkdirSync(skillDir, { recursive: true });
654+
fs.writeFileSync(
655+
path.join(skillDir, "SKILL.md"),
656+
"---\nname: manual-only\ndescription: Manual-only skill\nmetadata:\n allow-implicit-invocation: false\n---\n# Manual Only\n",
657+
"utf8"
658+
);
659+
660+
const manager = createSessionManager(workspace, "machine-id-manual-only-skill");
661+
const skill = (await manager.listSkills()).find((candidate) => candidate.name === "manual-only");
662+
assert.ok(skill);
663+
assert.equal(skill.allowImplicitInvocation, false);
664+
665+
const sessionId = await manager.createSession({ text: "", skills: [skill] });
666+
const skillMessages = manager
667+
.listSessionMessages(sessionId)
668+
.filter((message) => message.role === "system" && message.meta?.skill?.name === "manual-only");
669+
670+
assert.equal(skillMessages.length, 1);
671+
assert.match(skillMessages[0]?.content ?? "", /<manual-only-skill/);
672+
assert.doesNotMatch(skillMessages[0]?.content ?? "", /allow-implicit-invocation/);
673+
});
674+
675+
test("SessionManager excludes implicit opt-out skills from automatic matching candidates", async () => {
676+
const workspace = createTempDir("deepcode-implicit-opt-out-workspace-");
677+
const home = createTempDir("deepcode-implicit-opt-out-home-");
678+
setHomeDir(home);
679+
globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch;
680+
681+
const writeSkill = (name: string, metadata = ""): void => {
682+
const skillDir = path.join(workspace, ".deepcode", "skills", name);
683+
fs.mkdirSync(skillDir, { recursive: true });
684+
fs.writeFileSync(
685+
path.join(skillDir, "SKILL.md"),
686+
`---\nname: ${name}\ndescription: ${name} description${metadata}\n---\n# ${name}\n`,
687+
"utf8"
688+
);
689+
};
690+
writeSkill("auto-skill");
691+
writeSkill("manual-only", "\nmetadata:\n allow-implicit-invocation: false");
692+
693+
const requests: any[] = [];
694+
const client = {
695+
chat: {
696+
completions: {
697+
create: async (request: any) => {
698+
requests.push(request);
699+
if (isSkillMatchingRequest(request)) {
700+
return createSkillMatchingResponse(["manual-only", "auto-skill"]);
701+
}
702+
return createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 });
703+
},
704+
},
705+
},
706+
};
707+
const manager = createMockedClientSessionManagerWithClient(workspace, client);
708+
(manager as any).activateSession = async () => {};
709+
710+
const sessionId = await manager.createSession({ text: "choose an automatic skill" });
711+
const matchingPrompt = String(requests[0]?.messages?.[0]?.content ?? "");
712+
713+
assert.match(matchingPrompt, /"name": "auto-skill"/);
714+
assert.doesNotMatch(matchingPrompt, /"name": "manual-only"/);
715+
assert.equal(countLoadedSkillMessages(manager.listSessionMessages(sessionId), "auto-skill"), 1);
716+
assert.equal(countLoadedSkillMessages(manager.listSessionMessages(sessionId), "manual-only"), 0);
717+
});
718+
647719
test("SessionManager dispose disconnects MCP servers", async () => {
648720
const workspace = createTempDir("deepcode-mcp-dispose-workspace-");
649721
const serverPath = path.join(workspace, "mcp-server.cjs");

0 commit comments

Comments
 (0)