feat(skill): add invocation controls

This commit is contained in:
Yichen Jiang
2026-07-28 17:22:41 +08:00
parent 2a46685414
commit e133e4bddb
49 changed files with 646 additions and 157 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: c488fdc4b1d97b5aa1113e41a470484063526ded
README.zh.md: 796c814a3c4064d545a966465caf6f99e9dd8601
# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md
README.md: 56e19088e075f0ce9ad76eebdd23054e538174e4
README.zh.md: 38caf2716240700aab4220783068651c9dd851f7

View File

@@ -36,7 +36,9 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read
## Skill Format
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 YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case.
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.
## Model Experience

View File

@@ -36,7 +36,9 @@
## Skill 格式
Skill 可以是单层目录 bundle`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`。v1 刻意不包含嵌套 `**/SKILL.md` 发现。Frontmatter 使用 `yaml` 包解析为 YAML;它要求 `name``description` `whenToUse``disableModelInvocation``metadata` 可选。名称必须使用 kebab-case。
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` 并记录警告,而不会将其作为兼容别名。
## 模型体验

View File

@@ -22,6 +22,7 @@ import {
isSkillName,
type SkillCandidate,
type SkillDefinition,
type SkillInvocationPolicy,
type SkillLookupOptions,
type SkillProvider,
type SkillSource,
@@ -74,7 +75,7 @@ interface ParsedSkill {
name: string
description: string
whenToUse?: string
disableModelInvocation?: boolean
invocation?: SkillInvocationPolicy
metadata?: Record<string, unknown>
content: string
}
@@ -136,7 +137,7 @@ export class LocalSkillProvider implements SkillProvider {
name: parsed.name,
description: parsed.description,
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
...parsed.invocation !== undefined ? { invocation: parsed.invocation } : {},
source: candidate.source,
provider: this.name,
resourceBase: { kind: 'directory', path: locator.directory },
@@ -184,7 +185,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
name: parsed.name,
description: parsed.description,
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
...parsed.invocation !== undefined ? { invocation: parsed.invocation } : {},
provider: 'local',
source: root.source,
rank: root.rank,
@@ -263,11 +264,18 @@ async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal,
ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
return undefined
}
let invocation
try {
invocation = parseInvocationPolicy(parsed.data)
} catch (error) {
ctx.logger.warn(`skill file ${path} ignored: invalid invocation frontmatter: ${errorMessage(error)}`)
return undefined
}
return {
name,
description,
...optionalString(parsed.data, 'whenToUse'),
...optionalBoolean(parsed.data, 'disableModelInvocation'),
...invocation === undefined ? {} : { invocation },
...optionalMetadata(parsed.data),
content: parsed.body.trim(),
}
@@ -420,9 +428,43 @@ function optionalString(data: Record<string, unknown>, key: string): { [K in typ
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
}
function optionalBoolean(data: Record<string, unknown>, key: string): { [K in typeof key]?: boolean } {
function parseInvocationPolicy(data: Record<string, unknown>): SkillInvocationPolicy | undefined {
rejectLegacyInvocationKey(data, 'disableModelInvocation', '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 },
}
}
function rejectLegacyInvocationKey(data: Record<string, unknown>, legacy: string, canonical: string): void {
if (Object.hasOwn(data, legacy)) {
throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`)
}
}
function frontmatterBoolean(data: Record<string, unknown>, key: string): boolean | undefined {
if (!Object.hasOwn(data, key)) return undefined
const value = data[key]
return typeof value === 'boolean' ? { [key]: value } : {}
if (typeof value === 'boolean') return value
if (value === 1 || value === '1') return true
if (value === 0 || value === '0') return false
if (typeof value === 'string') {
switch (value.toLowerCase()) {
case 'true':
case 'yes':
case 'on':
return true
case 'false':
case 'no':
case 'off':
return false
}
}
throw new TypeError(`frontmatter field "${key}" must be a boolean`)
}
function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {

View File

@@ -200,7 +200,7 @@ describe('LocalSkillProvider', () => {
expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
})
it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
it('parses flat skills and filters invalid skills from the invocation-neutral listing', async () => {
const home = await tempDir('skill-flat')
const root = join(home, '.dsh/skills')
await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.')
@@ -209,7 +209,8 @@ describe('LocalSkillProvider', () => {
'name: rich-skill',
'description: rich description',
'whenToUse: For richer local parsing',
'disableModelInvocation: false',
'disable-model-invocation: off',
'user-invocable: YES',
'metadata:',
' owner: tests',
'---',
@@ -225,8 +226,10 @@ describe('LocalSkillProvider', () => {
await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
await writeFile(join(root, 'notes.txt'), 'ignored')
await mkdir(join(root, 'not-a-skill'), { recursive: true })
await writeSkill(root, 'hidden-skill', 'hidden description', 'Hidden.')
await writeFile(join(root, 'hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
await writeSkill(root, 'user-only-skill', 'user-only description', 'User-only.')
await writeFile(join(root, 'user-only-skill/SKILL.md'), '---\nname: user-only-skill\ndescription: user-only description\ndisable-model-invocation: true\n---\n\nUser-only.\n')
await writeSkill(root, 'model-only-skill', 'model-only description', 'Model-only.')
await writeFile(join(root, 'model-only-skill/SKILL.md'), '---\nname: model-only-skill\ndescription: model-only description\nuser-invocable: false\n---\n\nModel-only.\n')
const ctx = await setupLocal(home)
const listedBeforeDelete = await ctx.skills.list()
@@ -234,17 +237,88 @@ describe('LocalSkillProvider', () => {
if (flatSummary === undefined) throw new Error('expected flat-skill')
await writeFile(join(root, 'flat-skill.md'), '')
expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill'])
expect(listedBeforeDelete.map(skill => skill.name)).toEqual([
'flat-skill',
'model-only-skill',
'no-trailing-body',
'rich-skill',
'user-only-skill',
])
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
expect(await ctx.skills.get('user-only-skill')).toMatchObject({
invocation: { disableModelInvocation: true },
content: 'User-only.',
})
expect(await ctx.skills.get('model-only-skill')).toMatchObject({
invocation: { userInvocable: false },
content: 'Model-only.',
})
expect(await ctx.skills.get('rich-skill')).toMatchObject({
whenToUse: 'For richer local parsing',
disableModelInvocation: false,
invocation: { disableModelInvocation: false, userInvocable: true },
metadata: { owner: 'tests' },
})
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
})
it('accepts the documented boolean spellings for invocation frontmatter', async () => {
const home = await tempDir('skill-invocation-booleans')
const root = join(home, '.dsh/skills')
await mkdir(root, { recursive: true })
const truthy = ['true', 'TRUE', 'yes', 'ON', '1', '"1"']
const falsy = ['false', 'FALSE', 'no', 'OFF', '0', '"0"']
for (const [index, value] of truthy.entries()) {
await writeFile(join(root, `truthy-${index}.md`), [
'---',
`name: truthy-${index}`,
`description: Truthy ${index}`,
`disable-model-invocation: ${value}`,
'---',
'',
'Truthy.',
].join('\n'))
}
for (const [index, value] of falsy.entries()) {
await writeFile(join(root, `falsy-${index}.md`), [
'---',
`name: falsy-${index}`,
`description: Falsy ${index}`,
`user-invocable: ${value}`,
'---',
'',
'Falsy.',
].join('\n'))
}
const ctx = await setupLocal(home)
for (const [index] of truthy.entries()) {
expect((await ctx.skills.get(`truthy-${index}`))?.invocation).toEqual({ disableModelInvocation: true })
}
for (const [index] of falsy.entries()) {
expect((await ctx.skills.get(`falsy-${index}`))?.invocation).toEqual({ userInvocable: false })
}
})
it('rejects legacy and invalid invocation frontmatter without hiding valid siblings', async () => {
const home = await tempDir('skill-invalid-invocation')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'good-skill', 'Good skill')
const invalid = [
['legacy-model', 'disableModelInvocation: true'],
['legacy-user', 'userInvocable: false'],
['bad-string', 'disable-model-invocation: maybe'],
['bad-value', 'user-invocable: null'],
] as const
for (const [name, field] of invalid) {
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${name}\n${field}\n---\n\nBad.\n`)
}
const ctx = await setupLocal(home)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
})
it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
const home = await tempDir('skill-frontmatter-crlf')
const root = join(home, '.dsh/skills')

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 639616d0b75f960e9ccd48546d44db841372bbe2
README.zh.md: 3afdd415397927ebf107d6f862422c711a51888b
# pnpm run verify-translation-pairing --write packages/skill/skill/README.md
README.md: 30e48b57cd33c50013f857c61e63cba74fd2cd62
README.zh.md: 08b4a97a921565b5d69960e6f69d6ee6dd194986

View File

@@ -11,8 +11,8 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Public API
- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name.
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills.
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns every winning summary for the current workspace, merged across providers and sorted by name. The result is invocation-neutral; consumers apply `isModelInvocable(skill)` or `isUserInvocable(skill)` at their own boundary.
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it regardless of invocation policy.
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
### Config
@@ -21,6 +21,19 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|---|---|---|
| `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalogs kept in memory. |
### 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.
| Policy | Model | User |
|---|---|---|
| neither field, or `false` / `true` | included | included |
| `userInvocable: false` | included | excluded |
| `disableModelInvocation: true` | excluded | included |
| both restrictive values | 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.
## Provider Contract
A provider registers synchronously and performs remote setup, authentication, and discovery in its awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation.

View File

@@ -11,8 +11,8 @@
### 公开 API
- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect可安全用于 HMR精确的 Cordis disposer 支持有序组合拆卸。
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中的全部胜出摘要;这些摘要跨提供方合并,并按名称排序。结果与调用策略无关;消费方在自身边界调用 `isModelInvocable(skill)``isUserInvocable(skill)`
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后无论调用策略如何都将其返回。
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer以供有序组合拆卸。
### 配置
@@ -21,6 +21,19 @@
|---|---|---|
| `collectCacheMaxEntries` | `128` | 内存中保留的最大已完成 cwd/提供方目录数。 |
### 调用策略
`SkillSummary.invocation` 是类型化策略对象,其中包含可选的布尔字段 `disableModelInvocation``userInvocable`。字段缺失时保留模型和用户均可调用的默认行为。注册表保留全部四种组合,使一次发现结果可以同时服务面向模型的工具、面向用户的命令和受信内部调用方,而不会混淆各自的目录。
| 策略 | 模型 | 用户 |
|---|---|---|
| 两个字段均未设置,或分别为 `false` / `true` | 包含 | 包含 |
| `userInvocable: false` | 包含 | 排除 |
| `disableModelInvocation: true` | 排除 | 包含 |
| 两个限制值均已设置 | 排除 | 排除 |
`isModelInvocable(skill)` 仅在 `disableModelInvocation: true` 时返回 false`isUserInvocable(skill)` 仅在 `userInvocable: false` 时返回 false。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。
## 提供方契约
提供方同步注册,并在已等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。

View File

@@ -36,16 +36,24 @@ export type SkillResourceBase =
| { readonly kind: 'url'; readonly url: string }
| { readonly kind: 'opaque'; readonly description: string }
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
export interface SkillSummary {
/** Kebab-case identifier used with the `skill` tool. */
readonly name: string
/** Short routing description shown to the model. */
readonly description: string
/** Optional extra routing guidance shown to the model. */
readonly whenToUse?: string
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
/** Invocation controls shared by skill discovery consumers. */
export interface SkillInvocationPolicy {
/** Whether model-facing catalogs and loaders exclude this skill. */
readonly disableModelInvocation?: boolean
/** Whether human-facing command catalogs and loaders include this skill. */
readonly userInvocable?: boolean
}
/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
export interface SkillSummary {
/** Kebab-case identifier used to address the skill. */
readonly name: string
/** Short routing description shown by discovery consumers. */
readonly description: string
/** Optional extra routing guidance. */
readonly whenToUse?: string
/** Optional model and user invocation controls. */
readonly invocation?: SkillInvocationPolicy
/** Discovery source that produced this winning skill. */
readonly source: SkillSource
/** Provider that owns this skill body. */
@@ -87,6 +95,24 @@ export interface SkillLookupOptions {
readonly signal?: AbortSignal | undefined
}
/**
* 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.
*/
export function isModelInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation?.disableModelInvocation !== true
}
/**
* 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.
*/
export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation?.userInvocable !== false
}
/** Provider interface for one source of skills, such as local directories or a remote registry. */
export interface SkillProvider {
/** Unique provider name in the `ctx.skills` registry. */
@@ -221,16 +247,16 @@ export class SkillService extends Service {
}
/**
* List model-invocable skill summaries for a workspace. Lookup options and
* provider candidates are readonly same-process values borrowed throughout
* discovery.
* List invocation-neutral skill summaries for a workspace. Consumers apply
* model or user invocation policy at their operational boundary. Lookup
* options and provider candidates are readonly same-process values borrowed
* throughout discovery.
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
* @returns sorted summaries, excluding skills disabled for model invocation.
* @returns all sorted winning summaries.
*/
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
return (await this.collect(options))
.map(entry => entry.candidate)
.filter(skill => skill.disableModelInvocation !== true)
.map(toSummary)
.sort(compareSkillSummary)
}
@@ -359,7 +385,7 @@ function runtimeCandidate(skill: SkillRegistration): SkillCandidate {
name: skill.name,
description: skill.description,
...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},
...skill.disableModelInvocation !== undefined ? { disableModelInvocation: skill.disableModelInvocation } : {},
...skill.invocation !== undefined ? { invocation: skill.invocation } : {},
source: skill.source,
provider: skill.provider ?? RUNTIME_PROVIDER,
...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {},
@@ -383,9 +409,7 @@ function validateCandidate(candidate: SkillCandidate, providerName: string): voi
if (candidate.description.length === 0) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
}
if (candidate.disableModelInvocation !== undefined && typeof candidate.disableModelInvocation !== 'boolean') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-boolean disableModelInvocation`)
}
validateInvocation(candidate.invocation, `skill provider "${providerName}" returned skill "${candidate.name}"`)
if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`)
}
@@ -409,6 +433,7 @@ function validateCandidate(candidate: SkillCandidate, providerName: string): voi
function validateRuntimeSkill(skill: SkillRegistration): void {
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
validateInvocation(skill.invocation, `runtime skill "${skill.name}"`)
}
/** Validate a definition loaded from a provider-controlled parser or remote source. */
@@ -416,7 +441,7 @@ function validateDefinition(skill: SkillDefinition): void {
const name = skill.name
const description = skill.description
const whenToUse = skill.whenToUse
const disableModelInvocation = skill.disableModelInvocation
const invocation = skill.invocation
const source = skill.source
const provider = skill.provider
const content = skill.content
@@ -425,9 +450,7 @@ function validateDefinition(skill: SkillDefinition): void {
if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`)
if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`)
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`)
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
throw new TypeError(`loaded skill "${name}" disableModelInvocation must be a boolean`)
}
validateInvocation(invocation, `loaded skill "${name}"`)
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`)
if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`)
if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)
@@ -436,18 +459,32 @@ function validateDefinition(skill: SkillDefinition): void {
}
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
const { name, description, whenToUse, disableModelInvocation, source, provider, resourceBase } = skill
const { name, description, whenToUse, invocation, source, provider, resourceBase } = skill
return {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
...invocation !== undefined ? { invocation } : {},
source,
provider,
...resourceBase !== undefined ? { resourceBase } : {},
}
}
function validateInvocation(invocation: unknown, subject: string): void {
if (invocation === undefined) return
if (typeof invocation !== 'object' || invocation === null || Array.isArray(invocation)) {
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 (policy.userInvocable !== undefined && typeof policy.userInvocable !== 'boolean') {
throw new TypeError(`${subject} with a non-boolean invocation.userInvocable`)
}
}
function compareSkillSummary(left: SkillSummary, right: SkillSummary): number {
return compareCodePoints(left.name, right.name)
}

View File

@@ -1,6 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
import SkillService, {
isModelInvocable,
isUserInvocable,
type SkillCandidate,
type SkillDefinition,
type SkillLookupOptions,
type SkillProvider,
} from '@deepseek-ai/dsh-skill'
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
return {
@@ -107,6 +114,32 @@ describe('SkillService registry', () => {
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
})
it('returns an invocation-neutral catalog and resolves model and user policy independently', async () => {
const ctx = new Context()
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 } },
] as const
for (const registration of registrations) {
ctx.skills.register({
name: registration.name,
description: registration.name,
source: 'runtime',
...registration.invocation === undefined ? {} : { invocation: registration.invocation },
content: `${registration.name} body.`,
})
}
const listed = await ctx.skills.list()
expect(listed.map(skill => skill.name)).toEqual(['both', 'model-only', 'trusted-only', 'user-only'])
expect(listed.filter(isModelInvocable).map(skill => skill.name)).toEqual(['both', 'model-only'])
expect(listed.filter(isUserInvocable).map(skill => skill.name)).toEqual(['both', 'user-only'])
expect(await ctx.skills.get('trusted-only')).toMatchObject({ content: 'trusted-only body.' })
})
it('validates parsed candidate fields', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
@@ -117,7 +150,7 @@ describe('SkillService registry', () => {
...memorySkill('bad-candidate', 'placeholder', 1),
provider: 'bad-candidate',
description: badDescription as unknown as string,
disableModelInvocation: 'false' as unknown as boolean,
invocation: { disableModelInvocation: 'false' as unknown as boolean },
}]),
get: () => Promise.resolve(undefined),
})
@@ -130,11 +163,11 @@ describe('SkillService registry', () => {
list: () => Promise.resolve([{
...memorySkill('bad-boolean', 'Bad boolean', 1),
provider: 'bad-boolean',
disableModelInvocation: 'false' as unknown as boolean,
invocation: { disableModelInvocation: 'false' as unknown as boolean },
}]),
get: () => Promise.resolve(undefined),
})
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation')
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean invocation.disableModelInvocation')
})
it('rejects non-array provider results and every malformed candidate scalar', async () => {
@@ -163,7 +196,7 @@ describe('SkillService registry', () => {
name: `candidate-${index}`,
description: 'Candidate',
whenToUse: 'Use this candidate.',
disableModelInvocation: false,
invocation: { disableModelInvocation: false, userInvocable: true },
provider: providerName,
source: 'test',
rank: 1,
@@ -320,11 +353,12 @@ describe('SkillService registry', () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const locator = { id: 'provider-owned' }
const invocation = { disableModelInvocation: false, userInvocable: true }
const candidate: SkillCandidate = {
name: 'stable-skill',
description: 'Stable description',
whenToUse: 'When stability matters.',
disableModelInvocation: false,
invocation,
provider: 'detached',
source: 'test',
resourceBase: { kind: 'opaque', description: 'candidate resources' },
@@ -337,7 +371,7 @@ describe('SkillService registry', () => {
name: 'stable-skill',
description: 'Stable description',
whenToUse: 'When stability matters.',
disableModelInvocation: false,
invocation,
provider: 'detached',
source: 'test',
resourceBase: { kind: 'opaque', description: 'definition resources' },
@@ -366,6 +400,7 @@ describe('SkillService registry', () => {
resourceBase: { kind: 'opaque', description: 'candidate resources' },
})])
expect(listed[0]?.resourceBase).toBe(candidate.resourceBase)
expect(listed[0]?.invocation).toBe(invocation)
expect(listCalls).toBe(1)
const loaded = await ctx.skills.get('stable-skill')
@@ -379,11 +414,12 @@ 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 registration = {
name: 'runtime-skill',
description: 'Runtime',
whenToUse: 'When runtime data is needed.',
disableModelInvocation: false,
invocation,
source: 'runtime',
resourceBase,
metadata,
@@ -399,6 +435,7 @@ describe('SkillService registry', () => {
const listed = await ctx.skills.list()
const loaded = await ctx.skills.get('runtime-skill')
expect(listed[0]?.resourceBase).toBe(resourceBase)
expect(listed[0]?.invocation).toBe(invocation)
expect(loaded?.resourceBase).toBe(resourceBase)
expect(loaded?.metadata).toBe(metadata)
expect(loaded?.provider).toBe('runtime')
@@ -410,7 +447,15 @@ describe('SkillService registry', () => {
{ patch: { name: 'Bad_Name' }, expected: 'loaded skill has invalid name' },
{ patch: { description: { value: 'description' } as unknown as string }, expected: 'description must be a string' },
{ patch: { description: '' }, expected: 'requires a description' },
{ patch: { disableModelInvocation: 'false' as unknown as boolean }, expected: 'disableModelInvocation must be a boolean' },
{ patch: { invocation: null as never }, expected: 'non-object invocation policy' },
{
patch: { invocation: { disableModelInvocation: 'false' as unknown as boolean } },
expected: 'invocation.disableModelInvocation',
},
{
patch: { invocation: { userInvocable: 'true' as unknown as boolean } },
expected: 'invocation.userInvocable',
},
{ patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' },
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' },
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'provider must be a string' },
@@ -436,7 +481,7 @@ describe('SkillService registry', () => {
name: skillName,
description: 'Definition',
whenToUse: 'Use this definition.',
disableModelInvocation: false,
invocation: { disableModelInvocation: false, userInvocable: true },
provider: providerName,
source: 'test',
content: 'Definition body.',
@@ -668,6 +713,13 @@ describe('SkillService registry', () => {
await ctx.plugin(SkillService)
expect(() => ctx.skills.register({ name: 'Bad_Name', description: 'Bad', source: 'runtime', content: 'bad' })).toThrow('invalid skill name')
expect(() => ctx.skills.register({ name: 'no-description', description: '', source: 'runtime', content: 'bad' })).toThrow('requires a description')
expect(() => ctx.skills.register({
name: 'bad-invocation',
description: 'Bad invocation',
source: 'runtime',
invocation: [] as never,
content: 'bad',
})).toThrow('non-object invocation policy')
expect(await ctx.skills.get('missing-skill')).toBeUndefined()
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 6b0af04a15bfda985be3e04868f18b9af702eae7
README.zh.md: 4ca0ccd7734848d5774e92910e916bdf71c88c13
# pnpm run verify-translation-pairing --write packages/skill/tool-skill/README.md
README.md: 37cda665b74e186d26129c74401b4d8b24c9b7c3
README.zh.md: 849e44513c69ed11c311a1437c8a9a4af03a03f0

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 `disableModelInvocation: true` skills produce distinct error results.
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.
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 未知或已不可用。无效名称和 `disableModelInvocation: true` skill 产生不同的错误结果。
无法解析的名称会报告 skill 未知或已不可用。无效名称和 `invocation.disableModelInvocation``true` skill 产生不同的错误结果。`invocation.userInvocable` 不限制这个面向模型的接口。
该工具在 v1 中不调用 `agent.inject()`。其结果已作为工具结果记录,并在下一个模型步骤可用,无需将内容重复为合成上下文。

View File

@@ -9,7 +9,12 @@ import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { assertNever, type Message } from '@deepseek-ai/dsh-llm'
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
import {
isModelInvocable,
isSkillName,
type SkillDefinition,
type SkillSummary,
} from '@deepseek-ai/dsh-skill'
export const name = 'tool-skill'
export const inject = ['tools', 'skills']
@@ -91,7 +96,7 @@ export function apply(ctx: Context, config: Config = {}): void {
if (!skill) {
throw new Error(`skill "${args.name}" is unknown or no longer available`)
}
if (skill.disableModelInvocation === true) {
if (!isModelInvocable(skill)) {
throw new Error(`skill "${args.name}" is not available for model invocation`)
}
return {
@@ -123,7 +128,8 @@ export function apply(ctx: Context, config: Config = {}): void {
catalogLoaded.add(agent.session)
return
}
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
const skills = (await ctx.skills.list({ cwd: agent.session.header.cwd, signal }))
.filter(isModelInvocable)
if (skills.length > 0) {
const catalog = renderCatalogMessage(skills, catalogDescriptionMaxLength)
agent.inject({ content: catalog.content, source: { kind: 'plugin', plugin: 'dsh-tool-skill' } })

View File

@@ -143,6 +143,20 @@ describe('dsh-tool-skill', () => {
provider: 'runtime',
content: 'A body.',
})
ctx.skills.register({
name: 'model-only-skill',
description: 'Model-only skill.',
invocation: { userInvocable: false },
source: 'runtime',
content: 'Model-only body.',
})
ctx.skills.register({
name: 'user-only-skill',
description: 'User-only skill.',
invocation: { disableModelInvocation: true },
source: 'runtime',
content: 'User-only body.',
})
ctx.on('agent/step', (agent) => {
agent.inject({ content: [{ type: 'text', text: 'later contribution' }], source: { kind: 'plugin', plugin: 'later-contribution' } })
})
@@ -160,6 +174,7 @@ describe('dsh-tool-skill', () => {
'',
'<available_skills>',
'- `a-skill`: Use {{placeholder}} &lt;safely&gt; &amp; carefully.',
'- `model-only-skill`: Model-only skill.',
'- `z-skill`: Long description Long description Long descript...',
'</available_skills>',
'',
@@ -175,12 +190,20 @@ describe('dsh-tool-skill', () => {
expect(rendered).not.toContain('secret-source')
expect(rendered).not.toContain('/secret/path')
expect(rendered).not.toContain('Secret body')
expect(rendered).not.toContain('user-only-skill')
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
})
it('does not inject a catalog when no skills are available', async () => {
it('does not inject a catalog when no model-invocable skills are available', async () => {
const home = await tempDir('tool-empty-catalog')
const ctx = await setup(home)
ctx.skills.register({
name: 'user-only-skill',
description: 'User-only skill',
invocation: { disableModelInvocation: true },
source: 'runtime',
content: 'User-only body.',
})
expect(await composePrefix(ctx, '/workspace')).toEqual([])
})
@@ -333,16 +356,25 @@ describe('dsh-tool-skill', () => {
it('returns isError for unknown, invalid, and model-disabled skills', async () => {
const home = await tempDir('tool-errors')
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisable-model-invocation: true\n---\n\nHidden instructions.\n')
const ctx = await setup(home)
ctx.skills.register({
name: 'model-only-skill',
description: 'Model-only skill',
invocation: { userInvocable: false },
source: 'runtime',
content: 'Model-only instructions.',
})
const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
const modelOnly = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'model-only-skill' } })
expect(unknown.isError).toBe(true)
expect(invalid.isError).toBe(true)
expect(disabled.isError).toBe(true)
expect(modelOnly.isError).toBe(false)
const unknownBlock = unknown.content[0]
if (unknownBlock?.type !== 'text') throw new Error('expected text tool result')
expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available')