fix(skill): retain candidates across watcher failures
This commit is contained in:
@@ -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: 66b240c3a67941b2e617986bd43e6b0060b49f56
|
||||
README.zh.md: 0ebbab089999cbca07018724c05e40dd6b100200
|
||||
README.md: 9813338dcec82fc2db7e149be1bd80ec5239684d
|
||||
README.zh.md: abc637c1793b31158d015468941fd38ae06254c6
|
||||
|
||||
@@ -11,7 +11,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
### Public API
|
||||
|
||||
- `ctx.skills.registerProvider(create): () => void` Calls a synchronous provider factory with `{ signal, invalidate }`, then registers its readonly result by unique `provider.name`. Duplicate names throw, `runtime` is reserved, and failed registration aborts the signal. The exact Cordis disposer unregisters the provider, aborts the signal, and preserves ordered composite teardown.
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider failed transiently; incomplete observations are never cached, so a model-facing consumer can retain its last-good catalog and retry at the next request boundary.
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` Returns `{ skills, complete }`. `complete` is false when any provider rejects or explicitly reports incomplete discovery; candidates supplied with an incomplete observation remain in this result, which is never cached.
|
||||
- `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.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.
|
||||
@@ -28,11 +28,11 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
|
||||
## Provider Contract
|
||||
|
||||
A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's 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.
|
||||
A provider factory runs synchronously and receives one registration-scoped control. `control.signal` aborts when registration fails or is disposed; `control.invalidate()` clears completed catalogs only while that exact registration remains active, so late callbacks cannot affect a replacement with the same name. Immutable providers may ignore the control. Remote setup, authentication, and discovery belong in the provider's awaited `list(options)` call. An array return is shorthand for complete discovery; a provider that collected usable candidates but could not establish an authoritative observation returns `{ candidates, complete: false }`. 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.
|
||||
|
||||
The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract.
|
||||
|
||||
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure: its entries are omitted from that observation, `complete` is false, and the result is not cached. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
|
||||
Contract violations fail fast. A rejected provider `list()` is treated as a transient source failure and omitted. An explicit incomplete observation still contributes its candidates for `list()` and `get()`, but makes the aggregate snapshot incomplete and uncacheable. A provider or runtime revision change discards an in-flight result and retries before returning. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name.
|
||||
|
||||
Definitions remain progressively loaded. `get()` asks the winning provider for the body on every call rather than caching it in this registry. If the returned definition has a different name from the selected candidate, the stale selection is rejected and the registry internally invalidates that exact provider so the next snapshot rediscovers its catalog.
|
||||
|
||||
@@ -56,5 +56,5 @@ No direct prompt effect. The named consumer owns the durable initial catalog and
|
||||
|
||||
- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must retain and call its registration-scoped `invalidate()` capability from its own observation mechanism.
|
||||
- **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running.
|
||||
- **An incomplete snapshot omits the failing provider in that observation** — the registry reports `complete: false`, but it does not own a last-good catalog or a per-provider diagnostic; consumers choose whether to retain earlier state.
|
||||
- **Incomplete observations are not retained** — rejected providers are omitted and explicitly supplied candidates remain available only to the current lookup; the registry owns neither a last-good catalog nor per-provider diagnostics.
|
||||
- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
### 公开 API
|
||||
|
||||
- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false;不完整观测绝不缓存,使面向模型的消费方可以保留上一份可用目录,并在下一个请求边界重试。
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方调用被拒绝或显式报告发现不完整时,`complete` 为 false;不完整观测提供的候选项仍保留在该结果中,但该结果绝不缓存。
|
||||
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。
|
||||
- `ctx.skills.register(skill): () => void` 注册只读运行时嵌入式 skill,省略时添加 `provider: "runtime"`。同名运行时注册使用先到先得:重复项会记录警告,并获得无操作 disposer。成功注册会返回精确的 Cordis disposer,以供有序组合拆卸。
|
||||
@@ -28,11 +28,11 @@
|
||||
|
||||
## 提供方契约
|
||||
|
||||
提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
|
||||
提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。返回数组是完整发现的简写形式;若提供方已收集到可用候选项,却无法建立权威观测,则返回 `{ candidates, complete: false }`。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
|
||||
|
||||
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。
|
||||
|
||||
契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。
|
||||
契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败,并省略其结果。显式的不完整观测仍会为 `list()` 和 `get()` 提供其候选项,但会使聚合快照不完整且不可缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。
|
||||
|
||||
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。
|
||||
|
||||
@@ -56,5 +56,5 @@
|
||||
|
||||
- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。
|
||||
- **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。
|
||||
- **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。
|
||||
- **不保留不完整观测**:被拒绝的提供方会被省略,显式提供的候选项也仅在当前查找中可用;注册表既不负责上一份可用目录,也不负责逐提供方诊断。
|
||||
- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。
|
||||
|
||||
@@ -89,12 +89,20 @@ export interface SkillLookupOptions {
|
||||
|
||||
/** One catalog observation plus whether every registered provider completed discovery. */
|
||||
export interface SkillCatalogSnapshot {
|
||||
/** Sorted model-invocable summaries from providers that completed. */
|
||||
/** Sorted model-invocable summaries collected in this observation. */
|
||||
readonly skills: SkillSummary[]
|
||||
/** Whether every registered provider completed discovery for this observation. */
|
||||
readonly complete: boolean
|
||||
}
|
||||
|
||||
/** Provider candidates plus whether the current discovery is authoritative. */
|
||||
export interface SkillProviderObservation {
|
||||
/** Candidates available from the current provider discovery. */
|
||||
readonly candidates: readonly SkillCandidate[]
|
||||
/** Whether discovery completed and these candidates may be cached. */
|
||||
readonly complete: boolean
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -105,9 +113,10 @@ export interface SkillProvider {
|
||||
* authentication, and discovery are awaited inside this method. Implementations
|
||||
* should settle promptly when `options.signal` aborts.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns provider candidates with precedence ranks and opaque locators.
|
||||
* @returns provider candidates as a complete-array shorthand, or an explicit
|
||||
* observation when usable candidates came from incomplete discovery.
|
||||
*/
|
||||
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
|
||||
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>
|
||||
/**
|
||||
* Load a complete skill body for a previously listed candidate.
|
||||
* @param candidate - the winning candidate originally returned by this provider.
|
||||
@@ -387,11 +396,9 @@ export class SkillService extends Service {
|
||||
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
|
||||
}
|
||||
if (output === undefined) continue
|
||||
if (!Array.isArray(output)) {
|
||||
throw new TypeError(`skill provider "${provider.name}" list() must return an array`)
|
||||
}
|
||||
const listed = output as readonly SkillCandidate[]
|
||||
for (const candidate of listed) {
|
||||
const observation = normalizeProviderObservation(output, provider.name)
|
||||
if (!observation.complete) cacheable = false
|
||||
for (const candidate of observation.candidates) {
|
||||
validateCandidate(candidate, provider.name)
|
||||
candidates.push({ candidate, provider, providerOrder: order, localOrder })
|
||||
localOrder += 1
|
||||
@@ -426,6 +433,24 @@ export class SkillService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderObservation(output: unknown, providerName: string): SkillProviderObservation {
|
||||
if (Array.isArray(output)) {
|
||||
return { candidates: output as readonly SkillCandidate[], complete: true }
|
||||
}
|
||||
if (output === null || typeof output !== 'object') {
|
||||
throw invalidProviderObservation(providerName)
|
||||
}
|
||||
const observation = output as Partial<SkillProviderObservation>
|
||||
if (!Array.isArray(observation.candidates) || typeof observation.complete !== 'boolean') {
|
||||
throw invalidProviderObservation(providerName)
|
||||
}
|
||||
return observation as SkillProviderObservation
|
||||
}
|
||||
|
||||
function invalidProviderObservation(providerName: string): TypeError {
|
||||
return new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`)
|
||||
}
|
||||
|
||||
const RUNTIME_SKILL_PROVIDER: SkillProvider = {
|
||||
name: RUNTIME_PROVIDER,
|
||||
/* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider, type SkillProviderObservation } from '@deepseek-ai/dsh-skill'
|
||||
|
||||
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
|
||||
return {
|
||||
@@ -169,15 +169,18 @@ describe('SkillService registry', () => {
|
||||
await expect(badBoolean.skills.list()).rejects.toThrow('non-boolean disableModelInvocation')
|
||||
})
|
||||
|
||||
it('rejects non-array provider results and every malformed candidate scalar', async () => {
|
||||
const badList = new Context()
|
||||
await badList.plugin(SkillService)
|
||||
registerProvider(badList, {
|
||||
name: 'non-array-list',
|
||||
list: () => Promise.resolve({} as unknown as SkillCandidate[]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
await expect(badList.skills.list()).rejects.toThrow('list() must return an array')
|
||||
it('rejects malformed provider results and every malformed candidate scalar', async () => {
|
||||
const malformedOutputs: unknown[] = [null, 1, {}, { candidates: [], complete: 'yes' }]
|
||||
for (const [index, output] of malformedOutputs.entries()) {
|
||||
const badList = new Context()
|
||||
await badList.plugin(SkillService)
|
||||
registerProvider(badList, {
|
||||
name: `malformed-list-${index}`,
|
||||
list: () => Promise.resolve(output as readonly SkillCandidate[] | SkillProviderObservation),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
await expect(badList.skills.list()).rejects.toThrow('list() must return an array or { candidates, complete } observation')
|
||||
}
|
||||
|
||||
const cases: { patch: Partial<SkillCandidate>; expected: string }[] = [
|
||||
{ patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' },
|
||||
@@ -600,6 +603,33 @@ describe('SkillService registry', () => {
|
||||
expect(flakyCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('keeps candidates from incomplete provider observations loadable without caching them', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let listCalls = 0
|
||||
registerProvider(ctx, {
|
||||
name: 'incomplete-candidates',
|
||||
async list() {
|
||||
listCalls += 1
|
||||
return {
|
||||
candidates: [{ ...memorySkill('available-skill', 'Available', 10), provider: 'incomplete-candidates' }],
|
||||
complete: false,
|
||||
}
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
})
|
||||
|
||||
expect(await ctx.skills.snapshot()).toMatchObject({
|
||||
skills: [{ name: 'available-skill' }],
|
||||
complete: false,
|
||||
})
|
||||
expect((await ctx.skills.get('available-skill'))?.content).toBe('available-skill body.')
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['available-skill'])
|
||||
expect(listCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('invalidates only the exact registered provider and ignores its late callbacks', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
|
||||
Reference in New Issue
Block a user