mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 13:02:15 +00:00
feat(coding-agent): support disable-model-invocation frontmatter for skills
When set to true, the skill is hidden from the system prompt, preventing agentic invocation. Users can still invoke explicitly via /skill:name. Also fixes pre-existing test bug where source expectation was wrong. Fixes #927
This commit is contained in:
parent
3235926eed
commit
951fb953ed
8 changed files with 99 additions and 3 deletions
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
### Added
|
### 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))
|
- 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))
|
- 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))
|
- Header values in `models.json` now support environment variables and shell commands, matching `apiKey` resolution ([#909](https://github.com/badlogic/pi-mono/issues/909))
|
||||||
|
|
|
||||||
|
|
@ -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.). |
|
| `compatibility` | No | Max 500 chars. Environment requirements (system packages, network access, etc.). |
|
||||||
| `metadata` | No | Arbitrary key-value mapping for additional metadata. |
|
| `metadata` | No | Arbitrary key-value mapping for additional metadata. |
|
||||||
| `allowed-tools` | No | Space-delimited list of pre-approved tools (experimental). |
|
| `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
|
#### Name Validation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const customSkill: Skill = {
|
||||||
filePath: "/virtual/SKILL.md",
|
filePath: "/virtual/SKILL.md",
|
||||||
baseDir: "/virtual",
|
baseDir: "/virtual",
|
||||||
source: "path",
|
source: "path",
|
||||||
|
disableModelInvocation: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const loader = new DefaultResourceLoader({
|
const loader = new DefaultResourceLoader({
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const ALLOWED_FRONTMATTER_FIELDS = new Set([
|
||||||
"compatibility",
|
"compatibility",
|
||||||
"metadata",
|
"metadata",
|
||||||
"allowed-tools",
|
"allowed-tools",
|
||||||
|
"disable-model-invocation",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Max name length per spec */
|
/** Max name length per spec */
|
||||||
|
|
@ -27,6 +28,7 @@ const MAX_DESCRIPTION_LENGTH = 1024;
|
||||||
export interface SkillFrontmatter {
|
export interface SkillFrontmatter {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
"disable-model-invocation"?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +38,7 @@ export interface Skill {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
baseDir: string;
|
baseDir: string;
|
||||||
source: string;
|
source: string;
|
||||||
|
disableModelInvocation: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadSkillsResult {
|
export interface LoadSkillsResult {
|
||||||
|
|
@ -231,6 +234,7 @@ function loadSkillFromFile(
|
||||||
filePath,
|
filePath,
|
||||||
baseDir: skillDir,
|
baseDir: skillDir,
|
||||||
source,
|
source,
|
||||||
|
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
|
||||||
},
|
},
|
||||||
diagnostics,
|
diagnostics,
|
||||||
};
|
};
|
||||||
|
|
@ -245,9 +249,14 @@ function loadSkillFromFile(
|
||||||
* Format skills for inclusion in a system prompt.
|
* Format skills for inclusion in a system prompt.
|
||||||
* Uses XML format per Agent Skills standard.
|
* Uses XML format per Agent Skills standard.
|
||||||
* See: https://agentskills.io/integrate-skills
|
* 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 {
|
export function formatSkillsForPrompt(skills: Skill[]): string {
|
||||||
if (skills.length === 0) {
|
const visibleSkills = skills.filter((s) => !s.disableModelInvocation);
|
||||||
|
|
||||||
|
if (visibleSkills.length === 0) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,7 +267,7 @@ export function formatSkillsForPrompt(skills: Skill[]): string {
|
||||||
"<available_skills>",
|
"<available_skills>",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const skill of skills) {
|
for (const skill of visibleSkills) {
|
||||||
lines.push(" <skill>");
|
lines.push(" <skill>");
|
||||||
lines.push(` <name>${escapeXml(skill.name)}</name>`);
|
lines.push(` <name>${escapeXml(skill.name)}</name>`);
|
||||||
lines.push(` <description>${escapeXml(skill.description)}</description>`);
|
lines.push(` <description>${escapeXml(skill.description)}</description>`);
|
||||||
|
|
|
||||||
9
packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md
vendored
Normal file
9
packages/coding-agent/test/fixtures/skills/disable-model-invocation/SKILL.md
vendored
Normal file
|
|
@ -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.
|
||||||
|
|
@ -155,6 +155,7 @@ Content`,
|
||||||
filePath: "/fake/path",
|
filePath: "/fake/path",
|
||||||
baseDir: "/fake",
|
baseDir: "/fake",
|
||||||
source: "custom",
|
source: "custom",
|
||||||
|
disableModelInvocation: false,
|
||||||
};
|
};
|
||||||
const loader = new DefaultResourceLoader({
|
const loader = new DefaultResourceLoader({
|
||||||
cwd,
|
cwd,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ This is a test skill.
|
||||||
filePath: "/fake/path/SKILL.md",
|
filePath: "/fake/path/SKILL.md",
|
||||||
baseDir: "/fake/path",
|
baseDir: "/fake/path",
|
||||||
source: "custom" as const,
|
source: "custom" as const,
|
||||||
|
disableModelInvocation: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const resourceLoader: ResourceLoader = {
|
const resourceLoader: ResourceLoader = {
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,31 @@ describe("skills", () => {
|
||||||
expect(skills).toHaveLength(1);
|
expect(skills).toHaveLength(1);
|
||||||
expect(skills[0].name).toBe("valid-skill");
|
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", () => {
|
describe("formatSkillsForPrompt", () => {
|
||||||
|
|
@ -184,6 +209,7 @@ describe("skills", () => {
|
||||||
filePath: "/path/to/skill/SKILL.md",
|
filePath: "/path/to/skill/SKILL.md",
|
||||||
baseDir: "/path/to/skill",
|
baseDir: "/path/to/skill",
|
||||||
source: "test",
|
source: "test",
|
||||||
|
disableModelInvocation: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -205,6 +231,7 @@ describe("skills", () => {
|
||||||
filePath: "/path/to/skill/SKILL.md",
|
filePath: "/path/to/skill/SKILL.md",
|
||||||
baseDir: "/path/to/skill",
|
baseDir: "/path/to/skill",
|
||||||
source: "test",
|
source: "test",
|
||||||
|
disableModelInvocation: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -224,6 +251,7 @@ describe("skills", () => {
|
||||||
filePath: "/path/to/skill/SKILL.md",
|
filePath: "/path/to/skill/SKILL.md",
|
||||||
baseDir: "/path/to/skill",
|
baseDir: "/path/to/skill",
|
||||||
source: "test",
|
source: "test",
|
||||||
|
disableModelInvocation: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -242,6 +270,7 @@ describe("skills", () => {
|
||||||
filePath: "/path/one/SKILL.md",
|
filePath: "/path/one/SKILL.md",
|
||||||
baseDir: "/path/one",
|
baseDir: "/path/one",
|
||||||
source: "test",
|
source: "test",
|
||||||
|
disableModelInvocation: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "skill-two",
|
name: "skill-two",
|
||||||
|
|
@ -249,6 +278,7 @@ describe("skills", () => {
|
||||||
filePath: "/path/two/SKILL.md",
|
filePath: "/path/two/SKILL.md",
|
||||||
baseDir: "/path/two",
|
baseDir: "/path/two",
|
||||||
source: "test",
|
source: "test",
|
||||||
|
disableModelInvocation: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -258,6 +288,49 @@ describe("skills", () => {
|
||||||
expect(result).toContain("<name>skill-two</name>");
|
expect(result).toContain("<name>skill-two</name>");
|
||||||
expect((result.match(/<skill>/g) || []).length).toBe(2);
|
expect((result.match(/<skill>/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("<name>visible-skill</name>");
|
||||||
|
expect(result).not.toContain("<name>hidden-skill</name>");
|
||||||
|
expect((result.match(/<skill>/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", () => {
|
describe("loadSkills with options", () => {
|
||||||
|
|
@ -271,7 +344,7 @@ describe("skills", () => {
|
||||||
skillPaths: [join(fixturesDir, "valid-skill")],
|
skillPaths: [join(fixturesDir, "valid-skill")],
|
||||||
});
|
});
|
||||||
expect(skills).toHaveLength(1);
|
expect(skills).toHaveLength(1);
|
||||||
expect(skills[0].source).toBe("custom");
|
expect(skills[0].source).toBe("path");
|
||||||
expect(diagnostics).toHaveLength(0);
|
expect(diagnostics).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue