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 .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md
2026-07-28-skill-invocation-policy.md: e2c01c4343fec72ba285b13f78c1995b05dae928
2026-07-28-skill-invocation-policy.zh.md: d5f9bac59fd3d21107ed3ffca52fa16038a63832
2026-07-28-skill-invocation-policy.md: dd97f6f9d26de7588cac61e480c3a16fa6e6173a
2026-07-28-skill-invocation-policy.zh.md: c4c89bbe30a0bc53f36250bde74f2c66903f29d2

View File

@@ -8,26 +8,26 @@ English | [中文](2026-07-28-skill-invocation-policy.zh.md)
The skill registry originally treated discovery as a model catalog: `ctx.skills.list()` removed model-disabled skills, while `ctx.skills.get()` remained an unfiltered trusted loader. That was enough for model-initiated loading, but it could not represent Claude-compatible skills that are advertised only to a person, only to a model, to both, or to neither. The TUI compounded the mismatch by deriving user autocomplete from the model-filtered list and allowing every exact name through `get()`.
The local parser also exposed the internal camel-case `disableModelInvocation` spelling as frontmatter. Supporting the established `disable-model-invocation` and `user-invocable` fields requires a durable domain representation without turning every possible YAML key into an untyped cross-package contract.
The local parser also exposed an internal camel-case spelling as frontmatter. Supporting the established negative `disable-model-invocation` and positive `user-invocable` fields requires a durable, symmetric domain representation without turning every possible YAML key into an untyped cross-package contract.
## Decision
`SkillSummary` carries an optional typed `invocation: SkillInvocationPolicy` object. Its current fields are `disableModelInvocation?: boolean` and `userInvocable?: boolean`; future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist. The local provider still parses frontmatter as an open `Record<string, unknown>`, then projects only recognized fields into the typed policy.
`SkillSummary` carries an optional typed `invocation: SkillInvocationPolicy` object. When present, its `modelInvocable: boolean` and `userInvocable: boolean` fields are both required, positive, and symmetric; future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist. The local provider still parses frontmatter as an open `Record<string, unknown>`, then projects only recognized fields and their defaults into the normalized typed policy.
`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` excludes only `disableModelInvocation: true`; `isUserInvocable(skill)` excludes only `userInvocable: false`. Missing fields preserve model and user invocation. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill.
`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field; an absent policy permits both surfaces. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill.
The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. The old camel-case spellings are rejected with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias.
The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills the other field's default whenever either key is present. Camel-case external spellings are rejected with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias.
The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce `isUserInvocable`, so a user-only skill is visible and loadable there even when it is absent from model discovery. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added.
The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added.
These rules permit all four combinations:
| Policy | Model surface | User surface |
|---|---|---|
| defaults | included | included |
| `userInvocable: false` | included | excluded |
| `disableModelInvocation: true` | excluded | included |
| both restrictive values | excluded | excluded |
| no policy, 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 |
This decision extends the [skill system](2026-07-05-skill-system.md) and supersedes the invocation-policy limitation recorded by the [TUI skill slash command](2026-07-21-tui-skill-slash-command.md).

View File

@@ -8,26 +8,26 @@ Status: implemented
skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会移除禁止模型调用的 skill`ctx.skills.get()` 仍是不过滤内容的可信 loader。该设计足以支持由模型发起的加载却无法表示与 Claude 兼容的四类 skill仅向用户公开、仅向模型公开、同时向两者公开或者两者均不公开。TUI 从面向模型过滤后的列表中生成用户自动补全,并允许通过 `get()` 加载任意精确名称,这进一步放大了两类调用策略不匹配的问题。
本地解析器还将内部使用的驼峰式 `disableModelInvocation` 拼写暴露为 frontmatter。若要支持既有的 `disable-model-invocation``user-invocable` 字段,需要建立持久的领域表示,同时避免把所有可能出现的 YAML 键都变成跨包的无类型契约。
本地解析器还将一种内部驼峰式拼写暴露为 frontmatter。若要支持既有的负向字段 `disable-model-invocation`正向字段 `user-invocable`,需要建立持久且对称的领域表示,同时避免把所有可能出现的 YAML 键都变成跨包的无类型契约。
## 决策
`SkillSummary` 包含一个可选且类型明确的 `invocation: SkillInvocationPolicy` 对象。该对象当前提供 `disableModelInvocation?: boolean``userInvocable?: boolean` 两个字段;未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型。本地提供方仍将 frontmatter 解析为开放的 `Record<string, unknown>`,然后只把已识别字段投影到类型化策略中。
`SkillSummary` 包含一个可选且类型明确的 `invocation: SkillInvocationPolicy` 对象。该对象存在时,`modelInvocable: boolean``userInvocable: boolean` 都是必填、正向且对称的字段;未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型。本地提供方仍将 frontmatter 解析为开放的 `Record<string, unknown>`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。
`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` 只排除 `disableModelInvocation: true` 的 skill`isUserInvocable(skill)` 只排除 `userInvocable: false` 的 skill。字段缺失时模型和用户均可调用。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。
`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` `isUserInvocable(skill)` 分别读取对应的正向字段;策略缺失时两个接口均允许调用。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。
本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation``user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false``yes`/`no``on`/`off``1`/`0`,与 Claude skills 实际支持的布尔写法一致。的驼峰式拼写会被拒绝,并产生有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。
本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation``user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false``yes`/`no``on`/`off``1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,并在任一键存在时填充另一个字段的默认值。外部使用的驼峰式拼写会被拒绝,并产生有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。
面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 执行 `isUserInvocable`,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill本次改动不新增让浏览器直接加载 skill 的 RPC。
面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill本次改动不新增让浏览器直接加载 skill 的 RPC。
这些规则允许以下四种组合:
| 策略 | 模型侧接口 | 用户侧接口 |
|---|---|---|
| 默认值 | 包含 | 包含 |
| `userInvocable: false` | 包含 | 排除 |
| `disableModelInvocation: true` | 排除 | 包含 |
| 两个限制值同时存在 | 排除 | 排除 |
| 无策略,或 `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 |
| `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 |
| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 |
| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 |
该决策扩展了 [skill 系统](2026-07-05-skill-system.md),并取代 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)中记录的调用策略限制。

View File

@@ -1937,7 +1937,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
```
Source: [`packages/ui/tui/src/index.ts:191`](../../packages/ui/tui/src/index.ts)
Source: [`packages/ui/tui/src/index.ts:192`](../../packages/ui/tui/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`

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 docs/core-data-structures/skills.md
skills.md: e641ffd578e52dcda8a1c191123c097333c6f238
skills.zh.md: c5de8201c4e762438d15f12b169556543b6758b1
skills.md: 850aa589055f5e611619868ef6d05b3165f917a4
skills.zh.md: da36f301b6a8b38d83a190f759f9f2ed82b8ce4b

View File

@@ -62,15 +62,15 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | '
## Summaries, candidates, and complete definitions
`SkillSummary` is the registry's invocation-neutral summary shape. Consumers choose which entries and fields to render; the model session catalog uses only model-invocable `name` and `description`, never the body or absolute file path. `SkillInvocationPolicy` keeps the two independent invocation controls typed without turning arbitrary frontmatter into the domain model.
`SkillSummary` is the registry's invocation-neutral summary shape. Consumers choose which entries and fields to render; the model session catalog uses only model-invocable `name` and `description`, never the body or absolute file path. `SkillInvocationPolicy` normalizes the two independent invocation controls into positive booleans without turning arbitrary frontmatter into the domain model.
```ts type-equiv
/** Invocation controls shared by skill discovery consumers. */
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
}
```
@@ -94,7 +94,7 @@ interface SkillSummary {
}
```
`ctx.skills.list()` preserves all four policy combinations. `isModelInvocable(skill)` excludes only `disableModelInvocation: true`, while `isUserInvocable(skill)` excludes only `userInvocable: false`; missing fields permit both surfaces. A model-only skill sets `userInvocable: false`, a user-only skill sets `disableModelInvocation: true`, and setting both restrictive values keeps the skill available only through trusted `ctx.skills.get()` callers. The local provider reads these values from the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`.
`ctx.skills.list()` preserves all four policy combinations. An absent `invocation` object permits both surfaces; when present, both booleans are required. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the corresponding positive field. A model-only skill sets `{ modelInvocable: true, userInvocable: false }`, a user-only skill sets `{ modelInvocable: false, userInvocable: true }`, and setting both fields to `false` keeps the skill available only through trusted `ctx.skills.get()` callers. The local provider reads the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`, applies their defaults, and projects them into this normalized policy.
`SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`.

View File

@@ -62,15 +62,15 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | '
## 摘要、候选项与完整定义
`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用模型可调用 skill 的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 使两个独立调用控制保持类型化,而不会把任意 frontmatter 纳入领域模型。
`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用模型可调用 skill 的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 两个独立调用控制规范化为正向布尔值,而不会把任意 frontmatter 纳入领域模型。
```ts type-equiv
/** Invocation controls shared by skill discovery consumers. */
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
}
```
@@ -94,7 +94,7 @@ interface SkillSummary {
}
```
`ctx.skills.list()` 保留全部四种策略组合。`isModelInvocable(skill)` 仅排除 `disableModelInvocation: true``isUserInvocable(skill)` 仅排除 `userInvocable: false`;字段缺失时两个接口均允许调用。仅供模型调用的 skill 设置 `userInvocable: false`,仅供用户调用的 skill 设置 `disableModelInvocation: true`同时设置两个限制值后,该 skill 只能由受信的 `ctx.skills.get()` 调用方获取。本地提供方名称完全匹配的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable` 读取这些值
`ctx.skills.list()` 保留全部四种策略组合。缺少 `invocation` 对象时两个接口均允许调用;该对象存在时,两个布尔字段都为必填。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的正向字段。仅供模型调用的 skill 设置 `{ modelInvocable: true, userInvocable: false }`,仅供用户调用的 skill 设置 `{ modelInvocable: false, userInvocable: true }`两个字段均设为 `false` 后,该 skill 只能由受信的 `ctx.skills.get()` 调用方获取。本地提供方读取名称完全匹配的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`,应用其默认值,再将其投影到这个规范化策略中
`SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时传回。

View File

@@ -2191,7 +2191,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SkillInvocationPolicy',
declaration: 'export interface SkillInvocationPolicy {\n readonly disableModelInvocation?: boolean;\n readonly userInvocable?: boolean;\n}',
declaration: 'export interface SkillInvocationPolicy {\n readonly modelInvocable: boolean;\n readonly userInvocable: boolean;\n}',
},
{
name: 'SkillLookupOptions',

View File

@@ -182,17 +182,17 @@ describe('skill.list', () => {
},
{
name: 'user-only', description: 'User-only',
invocation: { disableModelInvocation: true },
invocation: { modelInvocable: false, userInvocable: true },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'model-only', description: 'Model-only',
invocation: { userInvocable: false },
invocation: { modelInvocable: true, userInvocable: false },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
{
name: 'trusted-only', description: 'Trusted-only',
invocation: { disableModelInvocation: true, userInvocable: false },
invocation: { modelInvocable: false, userInvocable: false },
source: 'custom', provider: 'probe', rank: 0, locator: null,
},
])

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'],

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.',

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/tool-skill/README.md
README.md: 37cda665b74e186d26129c74401b4d8b24c9b7c3
README.zh.md: 849e44513c69ed11c311a1437c8a9a4af03a03f0
README.md: ddf2b7503e8dad47677aa5260b0aa3349f4c4dd1
README.zh.md: 80255f0c1f78842b848a89b0a322b677a1364641

View File

@@ -22,7 +22,7 @@ Execution uses the calling agent's `session.header.cwd` so workspace-sensitive p
Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance.
An unresolved name reports that the skill is unknown or no longer available. Invalid names and skills whose `invocation.disableModelInvocation` is `true` produce distinct error results. `invocation.userInvocable` does not restrict this model-facing surface.
An unresolved name reports that the skill is unknown or no longer available. Invalid names and skills whose `invocation.modelInvocable` is `false` produce distinct error results. `invocation.userInvocable` does not restrict this model-facing surface.
The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context.

View File

@@ -22,7 +22,7 @@
资源指引只会根据 `resourceBase` 解析指令显式引用的路径或 URL脚本、参考资料和产物按需加载结果不会列举 skill 目录。本地提供方可以提供目录,而远程或嵌入式提供方可以提供 URL 或不透明加载指引。
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.disableModelInvocation``true` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.modelInvocable``false` 的 skill 会产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。
该工具在 v1 中不调用 `agent.inject()`。其结果已作为工具结果记录,并在下一个模型步骤可用,无需将内容重复为合成上下文。

View File

@@ -146,14 +146,14 @@ describe('dsh-tool-skill', () => {
ctx.skills.register({
name: 'model-only-skill',
description: 'Model-only skill.',
invocation: { userInvocable: false },
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
content: 'Model-only body.',
})
ctx.skills.register({
name: 'user-only-skill',
description: 'User-only skill.',
invocation: { disableModelInvocation: true },
invocation: { modelInvocable: false, userInvocable: true },
source: 'runtime',
content: 'User-only body.',
})
@@ -200,7 +200,7 @@ describe('dsh-tool-skill', () => {
ctx.skills.register({
name: 'user-only-skill',
description: 'User-only skill',
invocation: { disableModelInvocation: true },
invocation: { modelInvocable: false, userInvocable: true },
source: 'runtime',
content: 'User-only body.',
})
@@ -361,7 +361,7 @@ describe('dsh-tool-skill', () => {
ctx.skills.register({
name: 'model-only-skill',
description: 'Model-only skill',
invocation: { userInvocable: false },
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
content: 'Model-only instructions.',
})

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/ui/tui/README.md
README.md: 76d7f09d8b69123705f0d30c203b01989afe605c
README.zh.md: 61fe34fc09ffea6d81442bcd23c99a340c1e0ac0
README.md: 126f39f37ed86d049da53facea5379841a314ad8
README.zh.md: 1dc7b925a0cd239f003e209f73841afd049d4dc9

View File

@@ -130,7 +130,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr
#### What the model sees
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill: autocomplete and exact invocation apply `userInvocable`, while `disableModelInvocation` does not restrict this surface. A user-disabled skill produces a warning and no user turn.
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill: autocomplete and exact invocation apply `invocation.userInvocable`, while `invocation.modelInvocable` does not restrict this surface. A user-disabled skill produces a warning and no user turn. The skill service is an optional peer; this policy check uses its type contract without introducing a runtime package dependency.
#### Token effect

View File

@@ -130,7 +130,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
#### 模型看到的内容
提交 `/skill:<name> [instructions]` 会加载具名 skill并交付一个文本块`<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型:自动补全和按精确名称调用都应用 `userInvocable``disableModelInvocation` 不限制这个接口。用户禁用的 skill 会产生一条警告,不会产生用户轮次。
提交 `/skill:<name> [instructions]` 会加载具名 skill并交付一个文本块`<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型:自动补全和按精确名称调用都应用 `invocation.userInvocable``invocation.modelInvocable` 不限制这个接口。用户禁用的 skill 会产生一条警告,不会产生用户轮次。skill 服务是可选 peer这项策略检查仅使用其类型契约不引入运行时包依赖。
#### Token 影响

View File

@@ -49,10 +49,7 @@ import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
// Type import also declaration-merges the optional `sessionPersistence`
// service onto `Context` so `ctx.get('sessionPersistence')` is typed.
import type {} from '@deepseek-ai/dsh-session-persistence'
import {
isUserInvocable,
type SkillService,
} from '@deepseek-ai/dsh-skill'
import type { SkillService, SkillSummary } from '@deepseek-ai/dsh-skill'
// Type import declaration-merges the `userInteraction` service onto `Context`;
// the ask-user-question queue is registered by ./chat/questions.
import type {} from '@deepseek-ai/dsh-user-interaction'
@@ -173,6 +170,10 @@ export type {
TuiViewport,
} from './extension/types.ts'
function isSkillUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation?.userInvocable !== false
}
declare module 'cordis' {
interface Context {
/** Terminal-only interaction service, available only while a TUI is mounted. */
@@ -1000,7 +1001,7 @@ export function createTuiChat(
const loadSkillCommands = (service: SkillService): void => {
service.list({ cwd, signal: skillAbort.signal }).then(
(summaries) => {
const invocable = summaries.filter(isUserInvocable)
const invocable = summaries.filter(isSkillUserInvocable)
if (disposed || invocable.length === 0) return
// The argument-hint slot shows in the menu but is never inserted on
// selection, so it carries the skill's scope instead of an
@@ -1213,7 +1214,7 @@ export function createTuiChat(
appendNotice(`Unknown skill: ${name}`, 'warning')
return
}
if (!isUserInvocable(skill)) {
if (!isSkillUserInvocable(skill)) {
appendNotice(`Skill "${name}" is not available for user invocation.`, 'warning')
return
}

View File

@@ -3547,21 +3547,21 @@ describe('skill slash command', () => {
skills.register({
name: 'user-only-skill',
description: 'User-only skill',
invocation: { disableModelInvocation: true },
invocation: { modelInvocable: false, userInvocable: true },
source: 'runtime',
content: 'User-only instructions body.',
})
skills.register({
name: 'model-only-skill',
description: 'Model-only skill',
invocation: { userInvocable: false },
invocation: { modelInvocable: true, userInvocable: false },
source: 'runtime',
content: 'Model-only instructions body.',
})
skills.register({
name: 'trusted-only-skill',
description: 'Trusted-only skill',
invocation: { disableModelInvocation: true, userInvocable: false },
invocation: { modelInvocable: false, userInvocable: false },
source: 'runtime',
content: 'Trusted-only instructions body.',
})