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/README.md
README.md: 30e48b57cd33c50013f857c61e63cba74fd2cd62
README.zh.md: 08b4a97a921565b5d69960e6f69d6ee6dd194986
README.md: fd994fb2d20d0d8027b33d1092b9a94de0375e15
README.zh.md: 13cf124ac55604cb3a9c31e0e79ce574ff466444

View File

@@ -23,16 +23,16 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Invocation policy
`SkillSummary.invocation` is a typed policy object with optional `disableModelInvocation` and `userInvocable` booleans. Missing values preserve the default model-and-user behavior. The registry keeps all four combinations so one discovery result can serve model-facing tools, human-facing commands, and trusted internal callers without conflating their catalogs.
`SkillSummary.invocation` is an optional typed policy object. When present, its required positive booleans `modelInvocable` and `userInvocable` describe the two surfaces independently; omitting the object preserves the default model-and-user behavior. The registry keeps all four combinations so one discovery result can serve model-facing tools, human-facing commands, and trusted internal callers without conflating their catalogs.
| Policy | Model | User |
|---|---|---|
| neither field, or `false` / `true` | included | included |
| `userInvocable: false` | included | excluded |
| `disableModelInvocation: true` | excluded | included |
| both restrictive values | excluded | excluded |
| no `invocation`, or `{ modelInvocable: true, userInvocable: true }` | included | included |
| `{ modelInvocable: true, userInvocable: false }` | included | excluded |
| `{ modelInvocable: false, userInvocable: true }` | excluded | included |
| `{ modelInvocable: false, userInvocable: false }` | excluded | excluded |
`isModelInvocable(skill)` returns false only for `disableModelInvocation: true`; `isUserInvocable(skill)` returns false only for `userInvocable: false`. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill.
`isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field, with an absent policy permitting both surfaces. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill.
## Provider Contract

View File

@@ -23,16 +23,16 @@
### 调用策略
`SkillSummary.invocation` 是类型化策略对象,其中包含可选的布尔字段 `disableModelInvocation` 和 `userInvocable`。字段缺失时保留模型和用户均可调用的默认行为。注册表保留全部四种组合,使一次发现结果可以同时服务面向模型的工具、面向用户的命令和受信内部调用方,而不会混淆各自的目录。
`SkillSummary.invocation` 是一个可选的类型化策略对象。该对象存在时,其必填的正向布尔字段 `modelInvocable` 和 `userInvocable` 分别描述两个接口;省略该对象时保留模型和用户均可调用的默认行为。注册表保留全部四种组合,使一次发现结果可以同时服务面向模型的工具、面向用户的命令和受信内部调用方,而不会混淆各自的目录。
| 策略 | 模型 | 用户 |
|---|---|---|
| 两个字段均未设置,或分别为 `false` / `true` | 包含 | 包含 |
| `userInvocable: false` | 包含 | 排除 |
| `disableModelInvocation: true` | 排除 | 包含 |
| 两个限制值均已设置 | 排除 | 排除 |
| 无 `invocation`,或 `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 |
| `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 |
| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 |
| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 |
`isModelInvocable(skill)` 仅在 `disableModelInvocation: true` 时返回 false;`isUserInvocable(skill)` 仅在 `userInvocable: false` 时返回 false。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。
`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的正向字段;策略缺失时两个接口均允许调用。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。
## 提供方契约

View File

@@ -38,10 +38,10 @@ export type SkillResourceBase =
/** Invocation controls shared by skill discovery consumers. */
export interface SkillInvocationPolicy {
/** Whether model-facing catalogs and loaders exclude this skill. */
readonly disableModelInvocation?: boolean
/** Whether model-facing catalogs and loaders include this skill. */
readonly modelInvocable: boolean
/** Whether human-facing command catalogs and loaders include this skill. */
readonly userInvocable?: boolean
readonly userInvocable: boolean
}
/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
@@ -98,16 +98,16 @@ export interface SkillLookupOptions {
/**
* Return whether a skill may be advertised to and loaded by a model.
* @param skill - skill metadata carrying optional invocation controls.
* @returns `false` only when model invocation is explicitly disabled.
* @returns whether the normalized policy permits model invocation.
*/
export function isModelInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation?.disableModelInvocation !== true
return skill.invocation?.modelInvocable !== false
}
/**
* Return whether a skill may be advertised to and loaded by a human-facing command.
* @param skill - skill metadata carrying optional invocation controls.
* @returns `false` only when user invocation is explicitly disabled.
* @returns whether the normalized policy permits user invocation.
*/
export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation?.userInvocable !== false
@@ -477,10 +477,10 @@ function validateInvocation(invocation: unknown, subject: string): void {
throw new TypeError(`${subject} with a non-object invocation policy`)
}
const policy = invocation as Record<string, unknown>
if (policy.disableModelInvocation !== undefined && typeof policy.disableModelInvocation !== 'boolean') {
throw new TypeError(`${subject} with a non-boolean invocation.disableModelInvocation`)
if (typeof policy.modelInvocable !== 'boolean') {
throw new TypeError(`${subject} with a non-boolean invocation.modelInvocable`)
}
if (policy.userInvocable !== undefined && typeof policy.userInvocable !== 'boolean') {
if (typeof policy.userInvocable !== 'boolean') {
throw new TypeError(`${subject} with a non-boolean invocation.userInvocable`)
}
}

View File

@@ -5,6 +5,7 @@ import SkillService, {
isUserInvocable,
type SkillCandidate,
type SkillDefinition,
type SkillInvocationPolicy,
type SkillLookupOptions,
type SkillProvider,
} from '@deepseek-ai/dsh-skill'
@@ -119,9 +120,9 @@ describe('SkillService registry', () => {
await ctx.plugin(SkillService)
const registrations = [
{ name: 'both', invocation: undefined },
{ name: 'model-only', invocation: { userInvocable: false } },
{ name: 'user-only', invocation: { disableModelInvocation: true } },
{ name: 'trusted-only', invocation: { disableModelInvocation: true, userInvocable: false } },
{ name: 'model-only', invocation: { modelInvocable: true, userInvocable: false } },
{ name: 'user-only', invocation: { modelInvocable: false, userInvocable: true } },
{ name: 'trusted-only', invocation: { modelInvocable: false, userInvocable: false } },
] as const
for (const registration of registrations) {
ctx.skills.register({
@@ -150,7 +151,7 @@ describe('SkillService registry', () => {
...memorySkill('bad-candidate', 'placeholder', 1),
provider: 'bad-candidate',
description: badDescription as unknown as string,
invocation: { disableModelInvocation: 'false' as unknown as boolean },
invocation: { modelInvocable: false, userInvocable: true },
}]),
get: () => Promise.resolve(undefined),
})
@@ -163,11 +164,11 @@ describe('SkillService registry', () => {
list: () => Promise.resolve([{
...memorySkill('bad-boolean', 'Bad boolean', 1),
provider: 'bad-boolean',
invocation: { disableModelInvocation: 'false' as unknown as boolean },
invocation: { modelInvocable: 'false' as unknown as boolean, userInvocable: true },
}]),
get: () => Promise.resolve(undefined),
})
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean invocation.disableModelInvocation')
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean invocation.modelInvocable')
})
it('rejects non-array provider results and every malformed candidate scalar', async () => {
@@ -196,7 +197,7 @@ describe('SkillService registry', () => {
name: `candidate-${index}`,
description: 'Candidate',
whenToUse: 'Use this candidate.',
invocation: { disableModelInvocation: false, userInvocable: true },
invocation: { modelInvocable: true, userInvocable: true },
provider: providerName,
source: 'test',
rank: 1,
@@ -353,7 +354,7 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const locator = { id: 'provider-owned' }
const invocation = { disableModelInvocation: false, userInvocable: true }
const invocation = { modelInvocable: true, userInvocable: true }
const candidate: SkillCandidate = {
name: 'stable-skill',
description: 'Stable description',
@@ -414,7 +415,7 @@ describe('SkillService registry', () => {
await ctx.plugin(SkillService)
const resourceBase = { kind: 'opaque' as const, description: 'runtime resources' }
const metadata = { owner: 'runtime' }
const invocation = { disableModelInvocation: false, userInvocable: true }
const invocation = { modelInvocable: true, userInvocable: true }
const registration = {
name: 'runtime-skill',
description: 'Runtime',
@@ -449,11 +450,19 @@ describe('SkillService registry', () => {
{ patch: { description: '' }, expected: 'requires a description' },
{ patch: { invocation: null as never }, expected: 'non-object invocation policy' },
{
patch: { invocation: { disableModelInvocation: 'false' as unknown as boolean } },
expected: 'invocation.disableModelInvocation',
patch: { invocation: { modelInvocable: 'false' as unknown as boolean, userInvocable: true } },
expected: 'invocation.modelInvocable',
},
{
patch: { invocation: { userInvocable: 'true' as unknown as boolean } },
patch: { invocation: { modelInvocable: true, userInvocable: 'true' as unknown as boolean } },
expected: 'invocation.userInvocable',
},
{
patch: { invocation: { userInvocable: true } as unknown as SkillInvocationPolicy },
expected: 'invocation.modelInvocable',
},
{
patch: { invocation: { modelInvocable: true } as unknown as SkillInvocationPolicy },
expected: 'invocation.userInvocable',
},
{ patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' },
@@ -481,7 +490,7 @@ describe('SkillService registry', () => {
name: skillName,
description: 'Definition',
whenToUse: 'Use this definition.',
invocation: { disableModelInvocation: false, userInvocable: true },
invocation: { modelInvocable: true, userInvocable: true },
provider: providerName,
source: 'test',
content: 'Definition body.',