diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2c0ce287..893c2568 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ ### Added +- `disable-model-invocation` frontmatter field for skills to prevent agentic invocation while still allowing explicit `/skill:name` commands ([#927](https://github.com/badlogic/pi-mono/issues/927)) - Exposed `copyToClipboard` utility for extensions ([#926](https://github.com/badlogic/pi-mono/issues/926) by [@mitsuhiko](https://github.com/mitsuhiko)) - Skill invocation messages are now collapsible in chat output, showing collapsed by default with skill name and expand hint ([#894](https://github.com/badlogic/pi-mono/issues/894)) - Header values in `models.json` now support environment variables and shell commands, matching `apiKey` resolution ([#909](https://github.com/badlogic/pi-mono/issues/909)) diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index c95c66b9..2f4918e5 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -97,6 +97,7 @@ Per the [Agent Skills specification](https://agentskills.io/specification#frontm | `compatibility` | No | Max 500 chars. Environment requirements (system packages, network access, etc.). | | `metadata` | No | Arbitrary key-value mapping for additional metadata. | | `allowed-tools` | No | Space-delimited list of pre-approved tools (experimental). | +| `disable-model-invocation` | No | When `true`, the skill is hidden from the system prompt. The model cannot invoke it agentically; users must use `/skill:name`. | #### Name Validation diff --git a/packages/coding-agent/examples/sdk/04-skills.ts b/packages/coding-agent/examples/sdk/04-skills.ts index e7e92a78..0e7aa7d0 100644 --- a/packages/coding-agent/examples/sdk/04-skills.ts +++ b/packages/coding-agent/examples/sdk/04-skills.ts @@ -14,6 +14,7 @@ const customSkill: Skill = { filePath: "/virtual/SKILL.md", baseDir: "/virtual", source: "path", + disableModelInvocation: false, }; const loader = new DefaultResourceLoader({ diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index b0d1247f..fbe74839 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -16,6 +16,7 @@ const ALLOWED_FRONTMATTER_FIELDS = new Set([ "compatibility", "metadata", "allowed-tools", + "disable-model-invocation", ]); /** Max name length per spec */ @@ -27,6 +28,7 @@ const MAX_DESCRIPTION_LENGTH = 1024; export interface SkillFrontmatter { name?: string; description?: string; + "disable-model-invocation"?: boolean; [key: string]: unknown; } @@ -36,6 +38,7 @@ export interface Skill { filePath: string; baseDir: string; source: string; + disableModelInvocation: boolean; } export interface LoadSkillsResult { @@ -231,6 +234,7 @@ function loadSkillFromFile( filePath, baseDir: skillDir, source, + disableModelInvocation: frontmatter["disable-model-invocation"] === true, }, diagnostics, }; @@ -245,9 +249,14 @@ function loadSkillFromFile( * Format skills for inclusion in a system prompt. * Uses XML format per Agent Skills standard. * See: https://agentskills.io/integrate-skills + * + * Skills with disableModelInvocation=true are excluded from the prompt + * (they can only be invoked explicitly via /skill:name commands). */ export function formatSkillsForPrompt(skills: Skill[]): string { - if (skills.length === 0) { + const visibleSkills = skills.filter((s) => !s.disableModelInvocation); + + if (visibleSkills.length === 0) { return ""; } @@ -258,7 +267,7 @@ export function formatSkillsForPrompt(skills: Skill[]): string { "", ]; - for (const skill of skills) { + for (const skill of visibleSkills) { lines.push(" "); lines.push(` ${escapeXml(skill.name)}`); lines.push(` ${escapeXml(skill.description)}`); diff --git a/packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md b/packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md new file mode 100644 index 00000000..a8ec9d35 --- /dev/null +++ b/packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md @@ -0,0 +1,9 @@ +--- +name: disable-model-invocation +description: A skill that cannot be invoked by the model. +disable-model-invocation: true +--- + +# Manual Only Skill + +This skill can only be invoked via /skill:disable-model-invocation. diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index cb0eea03..dc91fc3f 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -155,6 +155,7 @@ Content`, filePath: "/fake/path", baseDir: "/fake", source: "custom", + disableModelInvocation: false, }; const loader = new DefaultResourceLoader({ cwd, diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts index 2d5d86d6..48c96cdd 100644 --- a/packages/coding-agent/test/sdk-skills.test.ts +++ b/packages/coding-agent/test/sdk-skills.test.ts @@ -80,6 +80,7 @@ This is a test skill. filePath: "/fake/path/SKILL.md", baseDir: "/fake/path", source: "custom" as const, + disableModelInvocation: false, }; const resourceLoader: ResourceLoader = { diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 6dec6f4b..c4979efd 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -168,6 +168,31 @@ describe("skills", () => { expect(skills).toHaveLength(1); expect(skills[0].name).toBe("valid-skill"); }); + + it("should parse disable-model-invocation frontmatter field", () => { + const { skills, diagnostics } = loadSkillsFromDir({ + dir: join(fixturesDir, "disable-model-invocation"), + source: "test", + }); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe("disable-model-invocation"); + expect(skills[0].disableModelInvocation).toBe(true); + // Should not warn about unknown field + expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("unknown frontmatter field"))).toBe( + false, + ); + }); + + it("should default disableModelInvocation to false when not specified", () => { + const { skills } = loadSkillsFromDir({ + dir: join(fixturesDir, "valid-skill"), + source: "test", + }); + + expect(skills).toHaveLength(1); + expect(skills[0].disableModelInvocation).toBe(false); + }); }); describe("formatSkillsForPrompt", () => { @@ -184,6 +209,7 @@ describe("skills", () => { filePath: "/path/to/skill/SKILL.md", baseDir: "/path/to/skill", source: "test", + disableModelInvocation: false, }, ]; @@ -205,6 +231,7 @@ describe("skills", () => { filePath: "/path/to/skill/SKILL.md", baseDir: "/path/to/skill", source: "test", + disableModelInvocation: false, }, ]; @@ -224,6 +251,7 @@ describe("skills", () => { filePath: "/path/to/skill/SKILL.md", baseDir: "/path/to/skill", source: "test", + disableModelInvocation: false, }, ]; @@ -242,6 +270,7 @@ describe("skills", () => { filePath: "/path/one/SKILL.md", baseDir: "/path/one", source: "test", + disableModelInvocation: false, }, { name: "skill-two", @@ -249,6 +278,7 @@ describe("skills", () => { filePath: "/path/two/SKILL.md", baseDir: "/path/two", source: "test", + disableModelInvocation: false, }, ]; @@ -258,6 +288,49 @@ describe("skills", () => { expect(result).toContain("skill-two"); expect((result.match(//g) || []).length).toBe(2); }); + + it("should exclude skills with disableModelInvocation from prompt", () => { + const skills: Skill[] = [ + { + name: "visible-skill", + description: "A visible skill.", + filePath: "/path/visible/SKILL.md", + baseDir: "/path/visible", + source: "test", + disableModelInvocation: false, + }, + { + name: "hidden-skill", + description: "A hidden skill.", + filePath: "/path/hidden/SKILL.md", + baseDir: "/path/hidden", + source: "test", + disableModelInvocation: true, + }, + ]; + + const result = formatSkillsForPrompt(skills); + + expect(result).toContain("visible-skill"); + expect(result).not.toContain("hidden-skill"); + expect((result.match(//g) || []).length).toBe(1); + }); + + it("should return empty string when all skills have disableModelInvocation", () => { + const skills: Skill[] = [ + { + name: "hidden-skill", + description: "A hidden skill.", + filePath: "/path/hidden/SKILL.md", + baseDir: "/path/hidden", + source: "test", + disableModelInvocation: true, + }, + ]; + + const result = formatSkillsForPrompt(skills); + expect(result).toBe(""); + }); }); describe("loadSkills with options", () => { @@ -271,7 +344,7 @@ describe("skills", () => { skillPaths: [join(fixturesDir, "valid-skill")], }); expect(skills).toHaveLength(1); - expect(skills[0].source).toBe("custom"); + expect(skills[0].source).toBe("path"); expect(diagnostics).toHaveLength(0); });