fix(skill): normalize invocation policy

This commit is contained in:
Yichen Jiang
2026-07-28 17:55:49 +08:00
parent 09d14e344f
commit babb8f1496
28 changed files with 123 additions and 105 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md
README.md: 56e19088e075f0ce9ad76eebdd23054e538174e4
README.zh.md: 38caf2716240700aab4220783068651c9dd851f7
README.md: cbef5e489e4340e10f3337ffd1bdb64490af4bc1
README.zh.md: 492aad6ef6d16149b9256de3f6830e451777f7d1

View File

@@ -38,7 +38,7 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as an open YAML object with the `yaml` package; this provider currently interprets required `name` and `description`, plus optional `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable`. Names must be kebab-case.
The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. Omitted fields preserve both forms of invocation. The camel-case spellings `disableModelInvocation` and `userInvocable` are rejected with a warning instead of acting as compatibility aliases.
The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. If either field is present, the provider fills both positive internal policy values from these external defaults. The camel-case spellings `disableModelInvocation`, `modelInvocable`, and `userInvocable` are rejected with a warning instead of acting as compatibility aliases.
## Model Experience

View File

@@ -38,7 +38,7 @@
Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。v1 刻意不包含嵌套 `**/SKILL.md` 发现。Frontmatter 使用 `yaml` 包解析为开放的 YAML 对象;该提供方目前解析必填的 `name` 和 `description`,以及可选的 `whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable`。名称必须使用 kebab-case。
这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和加载器中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。省略字段时保留两种调用方式。系统会拒绝驼峰形式的 `disableModelInvocation` 和 `userInvocable` 并记录警告,而不会将其作为兼容别名。
这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和加载器中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。任一字段存在时,提供方都会按照这些外部默认值填充两个正向内部策略值。系统会拒绝驼峰形式的 `disableModelInvocation`、`modelInvocable` 和 `userInvocable` 并记录警告,而不会将其作为兼容别名。
## 模型体验

View File

@@ -430,13 +430,14 @@ function optionalString(data: Record<string, unknown>, key: string): { [K in typ
function parseInvocationPolicy(data: Record<string, unknown>): SkillInvocationPolicy | undefined {
rejectLegacyInvocationKey(data, 'disableModelInvocation', 'disable-model-invocation')
rejectLegacyInvocationKey(data, 'modelInvocable', 'disable-model-invocation')
rejectLegacyInvocationKey(data, 'userInvocable', 'user-invocable')
const disableModelInvocation = frontmatterBoolean(data, 'disable-model-invocation')
const userInvocable = frontmatterBoolean(data, 'user-invocable')
if (disableModelInvocation === undefined && userInvocable === undefined) return undefined
return {
...disableModelInvocation === undefined ? {} : { disableModelInvocation },
...userInvocable === undefined ? {} : { userInvocable },
modelInvocable: disableModelInvocation !== true,
userInvocable: userInvocable !== false,
}
}

View File

@@ -246,16 +246,16 @@ describe('LocalSkillProvider', () => {
])
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
expect(await ctx.skills.get('user-only-skill')).toMatchObject({
invocation: { disableModelInvocation: true },
invocation: { modelInvocable: false, userInvocable: true },
content: 'User-only.',
})
expect(await ctx.skills.get('model-only-skill')).toMatchObject({
invocation: { userInvocable: false },
invocation: { modelInvocable: true, userInvocable: false },
content: 'Model-only.',
})
expect(await ctx.skills.get('rich-skill')).toMatchObject({
whenToUse: 'For richer local parsing',
invocation: { disableModelInvocation: false, userInvocable: true },
invocation: { modelInvocable: true, userInvocable: true },
metadata: { owner: 'tests' },
})
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
@@ -293,10 +293,16 @@ describe('LocalSkillProvider', () => {
const ctx = await setupLocal(home)
for (const [index] of truthy.entries()) {
expect((await ctx.skills.get(`truthy-${index}`))?.invocation).toEqual({ disableModelInvocation: true })
expect((await ctx.skills.get(`truthy-${index}`))?.invocation).toEqual({
modelInvocable: false,
userInvocable: true,
})
}
for (const [index] of falsy.entries()) {
expect((await ctx.skills.get(`falsy-${index}`))?.invocation).toEqual({ userInvocable: false })
expect((await ctx.skills.get(`falsy-${index}`))?.invocation).toEqual({
modelInvocable: true,
userInvocable: false,
})
}
})
@@ -306,6 +312,7 @@ describe('LocalSkillProvider', () => {
await writeSkill(root, 'good-skill', 'Good skill')
const invalid = [
['legacy-model', 'disableModelInvocation: true'],
['legacy-positive-model', 'modelInvocable: false'],
['legacy-user', 'userInvocable: false'],
['bad-string', 'disable-model-invocation: maybe'],
['bad-value', 'user-invocable: null'],