Skip to content

Commit fbe1780

Browse files
authored
fix: allow disable-model-invocation skills to be loaded via systematic_skill tool (#80)
Skills with disable-model-invocation: true were filtered from both the tool description and the execute path, making them completely unreachable. Split getSystematicSkills() into getAllSkills() (for execution) and getDiscoverableSkills() (for description/hints) so hidden skills can still be loaded by explicit name while remaining absent from AI-visible context.
1 parent 5a81409 commit fbe1780

2 files changed

Lines changed: 119 additions & 6 deletions

File tree

src/lib/skill-tool.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,20 @@ export function discoverSkillFiles(dir: string, limit = 10): string {
8787
export function createSkillTool(options: SkillToolOptions): ToolDefinition {
8888
const { bundledSkillsDir, disabledSkills } = options
8989

90-
const getSystematicSkills = (): LoadedSkill[] => {
90+
const getAllSkills = (): LoadedSkill[] => {
9191
return findSkillsInDir(bundledSkillsDir)
9292
.filter((s) => !disabledSkills.includes(s.name))
9393
.map((skillInfo) => loadSkill(skillInfo))
9494
.filter((s): s is LoadedSkill => s !== null)
95-
.filter((s) => s.disableModelInvocation !== true)
9695
.sort((a, b) => a.name.localeCompare(b.name))
9796
}
9897

98+
const getDiscoverableSkills = (): LoadedSkill[] => {
99+
return getAllSkills().filter((s) => s.disableModelInvocation !== true)
100+
}
101+
99102
const buildDescription = (): string => {
100-
const skills = getSystematicSkills()
103+
const skills = getDiscoverableSkills()
101104

102105
if (skills.length === 0) {
103106
return 'Load a skill to get detailed instructions for a specific task. No skills are currently available.'
@@ -128,7 +131,7 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
128131
}
129132

130133
const buildParameterHint = (): string => {
131-
const skills = getSystematicSkills()
134+
const skills = getDiscoverableSkills()
132135
const examples = skills
133136
.slice(0, 3)
134137
.map((s) => `'systematic:${s.name}'`)
@@ -164,11 +167,13 @@ export function createSkillTool(options: SkillToolOptions): ToolDefinition {
164167
? requestedName.slice('systematic:'.length)
165168
: requestedName
166169

167-
const skills = getSystematicSkills()
170+
const skills = getAllSkills()
168171
const matchedSkill = skills.find((s) => s.name === normalizedName)
169172

170173
if (!matchedSkill) {
171-
const availableSystematic = skills.map((s) => s.prefixedName)
174+
const availableSystematic = getDiscoverableSkills().map(
175+
(s) => s.prefixedName,
176+
)
172177
throw new Error(
173178
`Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(', ')}`,
174179
)

tests/unit/skill-tool.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,40 @@ description: Disabled
144144
expect(tool.description).toContain('systematic:enabled-skill')
145145
expect(tool.description).not.toContain('systematic:disabled-skill')
146146
})
147+
148+
test('excludes disableModelInvocation skills from description', () => {
149+
const visibleDir = path.join(testDir, 'visible-skill')
150+
const hiddenDir = path.join(testDir, 'hidden-skill')
151+
fs.mkdirSync(visibleDir)
152+
fs.mkdirSync(hiddenDir)
153+
154+
fs.writeFileSync(
155+
path.join(visibleDir, 'SKILL.md'),
156+
`---
157+
name: visible-skill
158+
description: Visible to model
159+
---
160+
# Content`,
161+
)
162+
163+
fs.writeFileSync(
164+
path.join(hiddenDir, 'SKILL.md'),
165+
`---
166+
name: hidden-skill
167+
description: Hidden from model
168+
disable-model-invocation: true
169+
---
170+
# Content`,
171+
)
172+
173+
const tool = createSkillTool({
174+
bundledSkillsDir: testDir,
175+
disabledSkills: [],
176+
})
177+
178+
expect(tool.description).toContain('systematic:visible-skill')
179+
expect(tool.description).not.toContain('systematic:hidden-skill')
180+
})
147181
})
148182

149183
describe('execute', () => {
@@ -351,5 +385,79 @@ description: Test file limit
351385
const hasLimitedFiles = /file[0-9]\.ts/.test(result)
352386
expect(hasLimitedFiles).toBe(true)
353387
})
388+
389+
test('loads disableModelInvocation skill when explicitly requested', async () => {
390+
const hiddenDir = path.join(testDir, 'hidden-skill')
391+
fs.mkdirSync(hiddenDir)
392+
fs.writeFileSync(
393+
path.join(hiddenDir, 'SKILL.md'),
394+
`---
395+
name: hidden-skill
396+
description: Hidden from model
397+
disable-model-invocation: true
398+
---
399+
# Hidden Skill Content
400+
401+
This skill is only loadable by explicit request.`,
402+
)
403+
404+
const tool = createSkillTool({
405+
bundledSkillsDir: testDir,
406+
disabledSkills: [],
407+
})
408+
409+
expect(tool.description).not.toContain('hidden-skill')
410+
411+
const result = await tool.execute(
412+
{ name: 'systematic:hidden-skill' },
413+
mockContext,
414+
)
415+
416+
expect(result).toContain('systematic:hidden-skill')
417+
expect(result).toContain('# Hidden Skill Content')
418+
expect(result).toContain(
419+
'This skill is only loadable by explicit request.',
420+
)
421+
})
422+
423+
test('does not list disableModelInvocation skills in error suggestions', async () => {
424+
const visibleDir = path.join(testDir, 'visible-skill')
425+
const hiddenDir = path.join(testDir, 'hidden-skill')
426+
fs.mkdirSync(visibleDir)
427+
fs.mkdirSync(hiddenDir)
428+
429+
fs.writeFileSync(
430+
path.join(visibleDir, 'SKILL.md'),
431+
`---
432+
name: visible-skill
433+
description: Visible to model
434+
---
435+
# Content`,
436+
)
437+
438+
fs.writeFileSync(
439+
path.join(hiddenDir, 'SKILL.md'),
440+
`---
441+
name: hidden-skill
442+
description: Hidden from model
443+
disable-model-invocation: true
444+
---
445+
# Content`,
446+
)
447+
448+
const tool = createSkillTool({
449+
bundledSkillsDir: testDir,
450+
disabledSkills: [],
451+
})
452+
453+
try {
454+
await tool.execute({ name: 'nonexistent' }, mockContext)
455+
expect.unreachable('Should have thrown')
456+
} catch (error) {
457+
const message = (error as Error).message
458+
expect(message).toContain('systematic:visible-skill')
459+
expect(message).not.toContain('hidden-skill')
460+
}
461+
})
354462
})
355463
})

0 commit comments

Comments
 (0)