refactor(skill): scope provider invalidation
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
type SkillDefinition,
|
||||
type SkillLookupOptions,
|
||||
type SkillProvider,
|
||||
type SkillProviderControl,
|
||||
type SkillSource,
|
||||
} from '@deepseek-ai/dsh-skill'
|
||||
|
||||
@@ -119,8 +120,11 @@ interface ResolvedWatchConfig {
|
||||
|
||||
/** Register the local filesystem skill provider on `ctx.skills`. */
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const provider = new LocalSkillProvider(ctx, config)
|
||||
ctx.skills.registerProvider(provider)
|
||||
let provider!: LocalSkillProvider
|
||||
ctx.skills.registerProvider((control) => {
|
||||
provider = new LocalSkillProvider(ctx, control, config)
|
||||
return provider
|
||||
})
|
||||
ctx.effect(function* () {
|
||||
yield async () => { await provider.dispose() }
|
||||
}, 'skill-local watcher')
|
||||
@@ -138,12 +142,18 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
private readonly customSkillDirs: string[]
|
||||
private readonly watchManager: SkillWatchManager
|
||||
private readonly bundledSkillDir: string | undefined
|
||||
private disposal: Promise<void> | undefined
|
||||
|
||||
constructor(private readonly ctx: Context, config: Config = {}) {
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
control: SkillProviderControl,
|
||||
config: Config = {},
|
||||
) {
|
||||
this.dshHome = resolveDshHome(config.dshHome)
|
||||
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
|
||||
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
|
||||
this.watchManager = new SkillWatchManager(ctx, this, resolveWatchConfig(config))
|
||||
this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config))
|
||||
control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true })
|
||||
const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR
|
||||
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
|
||||
}
|
||||
@@ -197,9 +207,13 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
this.watchManager.observeHostMutation(path)
|
||||
}
|
||||
|
||||
/** Close every host watcher and contain late filesystem callbacks. */
|
||||
async dispose(): Promise<void> {
|
||||
await this.watchManager.dispose()
|
||||
/**
|
||||
* Close every host watcher and contain late filesystem callbacks.
|
||||
* @returns a shared promise that settles when every watcher reaches quiescence.
|
||||
*/
|
||||
dispose(): Promise<void> {
|
||||
this.disposal ??= this.watchManager.dispose()
|
||||
return this.disposal
|
||||
}
|
||||
|
||||
private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
|
||||
@@ -250,7 +264,7 @@ class SkillWatchManager {
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly provider: SkillProvider,
|
||||
private readonly invalidate: () => void,
|
||||
private readonly config: ResolvedWatchConfig,
|
||||
) {}
|
||||
|
||||
@@ -286,18 +300,17 @@ class SkillWatchManager {
|
||||
evictedProject = true
|
||||
}
|
||||
await Promise.all(pending)
|
||||
if (evictedProject) this.ctx.skills.invalidateProvider(this.provider)
|
||||
if (evictedProject) this.invalidate()
|
||||
}
|
||||
|
||||
observeHostMutation(path: string): void {
|
||||
if (this.closing) return
|
||||
const normalized = resolve(path)
|
||||
if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return
|
||||
this.ctx.skills.invalidateProvider(this.provider)
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.closing) return
|
||||
this.closing = true
|
||||
const states = [...this.roots.values()]
|
||||
this.roots.clear()
|
||||
@@ -376,6 +389,8 @@ class SkillWatchManager {
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis
|
||||
// service; keep skill filtering and invalidation here.
|
||||
private async openStableWatcher(state: RootWatchState): Promise<WatchHandle | undefined> {
|
||||
while (!this.closing && state.owners.size > 0) {
|
||||
const mode = await resolveRootWatchMode(state.root.path)
|
||||
@@ -489,7 +504,7 @@ class SkillWatchManager {
|
||||
queueMicrotask(() => {
|
||||
this.invalidationQueued = false
|
||||
if (this.closing) return
|
||||
this.ctx.skills.invalidateProvider(this.provider)
|
||||
this.invalidate()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -123,12 +123,8 @@ describe('skill-local watcher failures', () => {
|
||||
watchStabilityThresholdMs: 20,
|
||||
})
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['watched-skill'])
|
||||
const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills)
|
||||
let invalidations = 0
|
||||
ctx.skills.invalidateProvider = (provider) => {
|
||||
invalidations += 1
|
||||
invalidateProvider(provider)
|
||||
}
|
||||
ctx.on('skills/change', () => { invalidations += 1 })
|
||||
const first = watcherHarness.watchers[0]
|
||||
if (first === undefined) throw new Error('expected a root watcher')
|
||||
|
||||
@@ -169,14 +165,17 @@ describe('skill-local watcher failures', () => {
|
||||
watcherHarness.deferredReady = 1
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const provider = new SkillLocal.LocalSkillProvider(ctx, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
watch: true,
|
||||
watchPollIntervalMs: 10,
|
||||
watchStabilityThresholdMs: 20,
|
||||
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
|
||||
const disposeProvider = ctx.skills.registerProvider((control) => {
|
||||
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
watch: true,
|
||||
watchPollIntervalMs: 10,
|
||||
watchStabilityThresholdMs: 20,
|
||||
})
|
||||
return provider
|
||||
})
|
||||
ctx.skills.registerProvider(provider)
|
||||
|
||||
const discovery = provider.list({})
|
||||
await settle()
|
||||
@@ -187,6 +186,7 @@ describe('skill-local watcher failures', () => {
|
||||
first.emitter.emit('ready')
|
||||
|
||||
await Promise.all([discovery, disposal])
|
||||
disposeProvider()
|
||||
await settle()
|
||||
expect(first.closeCalls).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -198,14 +198,17 @@ describe('skill-local watcher failures', () => {
|
||||
watcherHarness.deferredReady = 1
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const provider = new SkillLocal.LocalSkillProvider(ctx, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
watch: true,
|
||||
watchPollIntervalMs: 10,
|
||||
watchStabilityThresholdMs: 20,
|
||||
let provider!: InstanceType<typeof SkillLocal.LocalSkillProvider>
|
||||
const disposeProvider = ctx.skills.registerProvider((control) => {
|
||||
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
watch: true,
|
||||
watchPollIntervalMs: 10,
|
||||
watchStabilityThresholdMs: 20,
|
||||
})
|
||||
return provider
|
||||
})
|
||||
ctx.skills.registerProvider(provider)
|
||||
|
||||
const discovery = provider.list({})
|
||||
await settle()
|
||||
@@ -216,5 +219,6 @@ describe('skill-local watcher failures', () => {
|
||||
|
||||
await expect(discovery).rejects.toThrow('opening failed during disposal')
|
||||
await disposal
|
||||
disposeProvider()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -572,12 +572,8 @@ describe('LocalSkillProvider', () => {
|
||||
const root = join(home, '.agents/skills')
|
||||
const ctx = await setupLocal(home)
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
const invalidateProvider = ctx.skills.invalidateProvider.bind(ctx.skills)
|
||||
let invalidations = 0
|
||||
ctx.skills.invalidateProvider = (provider) => {
|
||||
invalidations += 1
|
||||
invalidateProvider(provider)
|
||||
}
|
||||
ctx.on('skills/change', () => { invalidations += 1 })
|
||||
|
||||
await writeSkill(root, 'observed-skill', 'Observed skill')
|
||||
const path = join(root, 'observed-skill/SKILL.md')
|
||||
@@ -657,15 +653,18 @@ describe('LocalSkillProvider', () => {
|
||||
await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const provider = new SkillLocal.LocalSkillProvider(ctx, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
customSkillDirs: [nonDirectoryRoot],
|
||||
watch: true,
|
||||
watchStabilityThresholdMs: 20,
|
||||
watchPollIntervalMs: 10,
|
||||
let provider!: SkillLocal.LocalSkillProvider
|
||||
const disposeProvider = ctx.skills.registerProvider((control) => {
|
||||
provider = new SkillLocal.LocalSkillProvider(ctx, control, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
customSkillDirs: [nonDirectoryRoot],
|
||||
watch: true,
|
||||
watchStabilityThresholdMs: 20,
|
||||
watchPollIntervalMs: 10,
|
||||
})
|
||||
return provider
|
||||
})
|
||||
ctx.skills.registerProvider(provider)
|
||||
expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill'])
|
||||
|
||||
await provider.dispose()
|
||||
@@ -673,6 +672,7 @@ describe('LocalSkillProvider', () => {
|
||||
provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md'))
|
||||
|
||||
expect((await provider.list({})).map(skill => skill.name)).toEqual(['disposed-skill'])
|
||||
disposeProvider()
|
||||
})
|
||||
|
||||
it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => {
|
||||
@@ -740,7 +740,10 @@ describe('LocalSkillProvider', () => {
|
||||
expect(await empty.skills.list()).toEqual([])
|
||||
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
|
||||
expect(new SkillLocal.LocalSkillProvider(empty, {
|
||||
signal: new AbortController().signal,
|
||||
invalidate() {},
|
||||
}, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
|
||||
} finally {
|
||||
if (previousDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
|
||||
@@ -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: 54362bd3a0b8bcbf8161ce45f13535b49eab18a1
|
||||
README.zh.md: 8f15a44c815ffa687d01f8fc8f6070a8f1d28195
|
||||
README.md: 66b240c3a67941b2e617986bd43e6b0060b49f56
|
||||
README.zh.md: 0ebbab089999cbca07018724c05e40dd6b100200
|
||||
|
||||
@@ -10,8 +10,7 @@ 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.invalidateProvider(provider): void` Marks one exact live provider dirty and clears completed catalog caches. Calls from a disposed or replaced provider instance are no-ops, so late watcher callbacks cannot invalidate its replacement.
|
||||
- `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.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.
|
||||
@@ -19,7 +18,7 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
|
||||
### Events
|
||||
|
||||
- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after `invalidateProvider()` accepts an exact live provider. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners.
|
||||
- `skills/change` is an unfiltered invalidation notification emitted after a provider or runtime contribution is registered or disposed and after an active provider's registration control invalidates. It carries no catalog or diff: each consumer refetches `snapshot()` with its own lookup options. Listener throws and rejected promises are logged and cannot veto the registry mutation or starve later listeners.
|
||||
|
||||
### Config
|
||||
|
||||
@@ -29,13 +28,13 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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 that exact provider is invalidated so the next snapshot rediscovers its catalog.
|
||||
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.
|
||||
|
||||
## Runtime Skills
|
||||
|
||||
@@ -55,7 +54,7 @@ No direct prompt effect. The named consumer owns the durable initial catalog and
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Invalidation is provider-driven** — the registry has no TTL and cannot infer that an arbitrary remote source changed; each mutable provider must call `invalidateProvider()` from its own observation mechanism.
|
||||
- **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.
|
||||
- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions.
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
|
||||
### 公开 API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => void` 使用唯一 `provider.name` 注册只读提供方。重复提供方名称会抛错,`runtime` 保留给 `ctx.skills.register(...)`。注册表借用提供方对象,并直接调用其方法。注册作用域绑定到 effect,可安全用于 HMR;精确的 Cordis disposer 支持有序组合拆卸。
|
||||
- `ctx.skills.invalidateProvider(provider): void` 按实例精确标脏一个活动提供方,并清除已完成目录缓存。已释放或已被替换的提供方实例调用此方法时不执行任何操作,因此延迟到达的 watcher 回调无法使其替代项失效。
|
||||
- `ctx.skills.registerProvider(create): () => void` 调用同步提供方工厂并向其传入 `{ signal, invalidate }`,随后使用唯一 `provider.name` 注册其只读结果。重复提供方名称会抛错,`runtime` 为保留名称;注册失败会中止信号。精确的 Cordis disposer 会注销提供方、中止信号,并保持有序组合拆卸。
|
||||
- `ctx.skills.snapshot({ cwd?, signal? })` 返回 `{ skills, complete }`。任一提供方发生瞬时失败时,`complete` 为 false;不完整观测绝不缓存,使面向模型的消费方可以保留上一份可用目录,并在下一个请求边界重试。
|
||||
- `ctx.skills.list({ cwd?, signal? })` 借用只读查找选项,然后返回当前工作区中模型可调用的摘要;这些摘要跨提供方合并,并按名称排序。
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` 在发现和加载中使用同一组只读选项和胜出候选项;在发现或缓存命中后重新检查取消,让提供方加载与信号竞速,验证已加载定义,然后将其返回,包括已对模型禁用的 skill。
|
||||
@@ -19,7 +18,7 @@
|
||||
|
||||
### 事件
|
||||
|
||||
- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及 `invalidateProvider()` 接受精确活动提供方后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。
|
||||
- `skills/change` 是一条不带过滤条件的失效通知,在提供方或运行时贡献注册或释放后,以及活动提供方的注册控制触发失效后发出。它不携带目录或 diff;每个消费方都使用自身的查找选项重新获取 `snapshot()`。监听器抛错或 Promise 拒绝会被记录,既不能否决注册表变更,也不能阻止后续监听器执行。
|
||||
|
||||
### 配置
|
||||
|
||||
@@ -29,13 +28,13 @@
|
||||
|
||||
## 提供方契约
|
||||
|
||||
提供方同步注册,并在已等待的 `list(options)` 调用中执行远程设置、身份验证和发现。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
|
||||
提供方工厂同步运行,并接收一项注册作用域内的控制能力。注册失败或释放时,`control.signal` 会中止;仅当该精确注册仍处于活动状态时,`control.invalidate()` 才会清除已完成目录,因此延迟回调无法影响同名替代项。不可变提供方可以忽略该控制能力。远程设置、身份验证和发现属于提供方需等待的 `list(options)` 调用。提供方对象、查找选项、候选项和定义都以只读方式借用,而不是克隆或重新绑定。提供方应遵守 `options.signal`;取消后,注册表也会停止等待不协作的发现或加载。
|
||||
|
||||
注册表在缓存前验证候选项,在返回前验证定义。胜出提供方会收到同一候选项和不透明 `locator`,两者都是它从 `list()` 返回的内容,从而支持后端专用文件、URL、id 或版本句柄。调用方和提供方必须保持只读契约。
|
||||
|
||||
契约违反会快速失败。提供方 `list()` 被拒绝会视为瞬时来源失败:该次观测会省略其条目,`complete` 为 false,结果也不会缓存。提供方或运行时修订发生变更时,会丢弃正在进行的结果并重试后再返回。重复名称按 rank、提供方注册顺序,然后按提供方本地顺序解析。摘要按 skill 名称排序。
|
||||
|
||||
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并使该提供方实例失效,以便下一次快照重新发现其目录。
|
||||
定义仍采用渐进式加载。`get()` 每次调用都会向胜出提供方请求正文,而不是在此注册表中缓存正文。若返回定义的名称不同于所选候选项,系统会拒绝该陈旧选择,并由注册表在内部使该精确提供方失效,以便下一次快照重新发现其目录。
|
||||
|
||||
## 运行时 Skill
|
||||
|
||||
@@ -55,7 +54,7 @@
|
||||
|
||||
## 已知限制与待完成工作
|
||||
|
||||
- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须由自身的观测机制调用 `invalidateProvider()`。
|
||||
- **失效由提供方驱动**:注册表没有 TTL,无法推断任意远程来源是否已发生变化;每个可变提供方都必须保留其注册作用域内的 `invalidate()` 能力,并由自身的观测机制调用它。
|
||||
- **提供方依次查询**:一个缓慢的协作提供方会延迟之后注册的所有提供方;取消会停止调用方等待,但无法终止不协作提供方持续运行的工作。
|
||||
- **不完整快照会在该次观测中省略失败的提供方**:注册表会报告 `complete: false`,但不负责上一份可用目录或逐提供方诊断;消费方选择是否保留先前状态。
|
||||
- **重复解析使用先到先得**:系统会记录并隐藏较晚出现的低优先级候选项;不提供检查全部被遮蔽定义的 API。
|
||||
|
||||
@@ -117,6 +117,14 @@ export interface SkillProvider {
|
||||
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
|
||||
}
|
||||
|
||||
/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */
|
||||
export interface SkillProviderControl {
|
||||
/** Aborts if registration fails or when the exact provider registration is disposed. */
|
||||
readonly signal: AbortSignal
|
||||
/** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */
|
||||
readonly invalidate: () => void
|
||||
}
|
||||
|
||||
/** Skill registry configuration. */
|
||||
export interface Config {
|
||||
/** Maximum number of completed cwd/provider catalogs kept in memory. */
|
||||
@@ -180,43 +188,50 @@ export class SkillService extends Service {
|
||||
* Register a borrowed same-process provider synchronously during plugin apply. Duplicate and
|
||||
* reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters
|
||||
* the provider and invalidates catalog caches.
|
||||
* @param provider - the provider to register by `provider.name`.
|
||||
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.
|
||||
* @returns the exact Cordis effect disposer that unregisters this provider;
|
||||
* composite effects may yield it directly to preserve teardown ordering.
|
||||
*/
|
||||
registerProvider(provider: SkillProvider): () => void {
|
||||
const name = provider.name
|
||||
if (name === RUNTIME_PROVIDER) {
|
||||
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
|
||||
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void {
|
||||
const lifecycle = new AbortController()
|
||||
let active = false
|
||||
let provider: SkillProvider
|
||||
const control: SkillProviderControl = {
|
||||
signal: lifecycle.signal,
|
||||
invalidate: () => {
|
||||
if (active) this.invalidateProvider(provider)
|
||||
},
|
||||
}
|
||||
if (this.providers.has(name)) {
|
||||
throw new Error(`a skill provider named "${name}" is already registered`)
|
||||
}
|
||||
const providers = this.providers
|
||||
const order = this.nextProviderOrder
|
||||
const invalidateCache = (): void => { this.invalidateCache() }
|
||||
this.nextProviderOrder += 1
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
providers.set(name, { provider, order })
|
||||
invalidateCache()
|
||||
yield () => {
|
||||
providers.delete(name)
|
||||
invalidateCache()
|
||||
try {
|
||||
provider = create(control)
|
||||
const name = provider.name
|
||||
if (name === RUNTIME_PROVIDER) {
|
||||
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
|
||||
}
|
||||
}, 'skills.registerProvider()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate catalogs contributed by one currently registered provider. Exact object identity
|
||||
* prevents a late callback from an old provider instance from invalidating its replacement.
|
||||
* Calls for an already-unregistered provider are harmless.
|
||||
* @param provider - exact provider instance whose external source changed.
|
||||
*/
|
||||
invalidateProvider(provider: SkillProvider): void {
|
||||
if (this.providers.get(provider.name)?.provider !== provider) return
|
||||
this.invalidateCache()
|
||||
if (this.providers.has(name)) {
|
||||
throw new Error(`a skill provider named "${name}" is already registered`)
|
||||
}
|
||||
const providers = this.providers
|
||||
const order = this.nextProviderOrder
|
||||
const invalidateCache = (): void => { this.invalidateCache() }
|
||||
this.nextProviderOrder += 1
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
active = true
|
||||
providers.set(name, { provider, order })
|
||||
invalidateCache()
|
||||
yield () => {
|
||||
active = false
|
||||
providers.delete(name)
|
||||
lifecycle.abort(new Error(`skill provider "${name}" disposed`))
|
||||
invalidateCache()
|
||||
}
|
||||
}, 'skills.registerProvider()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
|
||||
return dispose
|
||||
} catch (error) {
|
||||
lifecycle.abort(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,6 +406,11 @@ export class SkillService extends Service {
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
private invalidateProvider(provider: SkillProvider): void {
|
||||
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */
|
||||
if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache()
|
||||
}
|
||||
|
||||
/** Notify catalog observers without making their refresh work load-bearing. */
|
||||
private notifyChange(): void {
|
||||
for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) {
|
||||
|
||||
@@ -34,6 +34,10 @@ class MemoryProvider implements SkillProvider {
|
||||
}
|
||||
}
|
||||
|
||||
function registerProvider(ctx: Context, provider: SkillProvider): () => void {
|
||||
return ctx.skills.registerProvider(() => provider)
|
||||
}
|
||||
|
||||
describe('SkillService registry', () => {
|
||||
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -59,8 +63,8 @@ describe('SkillService registry', () => {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
}
|
||||
const disposeMemory = ctx.skills.registerProvider(provider)
|
||||
ctx.skills.registerProvider(overrideProvider)
|
||||
const disposeMemory = registerProvider(ctx, provider)
|
||||
registerProvider(ctx, overrideProvider)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([
|
||||
['a-skill', 'A skill', 'memory'],
|
||||
@@ -84,24 +88,52 @@ describe('SkillService registry', () => {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider(sameRankProvider)
|
||||
registerProvider(ctx, sameRankProvider)
|
||||
expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank')
|
||||
await expect(ctx.plugin({
|
||||
name: 'duplicate-memory',
|
||||
inject: ['skills'],
|
||||
apply(pluginCtx: Context) {
|
||||
pluginCtx.skills.registerProvider(new MemoryProvider([]))
|
||||
registerProvider(pluginCtx, new MemoryProvider([]))
|
||||
},
|
||||
})).rejects.toThrow('already registered')
|
||||
expect(() => ctx.skills.registerProvider({
|
||||
name: 'runtime',
|
||||
async list() {
|
||||
return []
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
let rejectedSignal: AbortSignal | undefined
|
||||
expect(() => ctx.skills.registerProvider((control) => {
|
||||
rejectedSignal = control.signal
|
||||
return {
|
||||
name: 'runtime',
|
||||
async list() {
|
||||
return []
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
})).toThrow('reserved')
|
||||
expect(rejectedSignal?.aborted).toBe(true)
|
||||
|
||||
const factoryFailure = new Error('factory failed')
|
||||
let failedSignal: AbortSignal | undefined
|
||||
expect(() => ctx.skills.registerProvider((control) => {
|
||||
failedSignal = control.signal
|
||||
throw factoryFailure
|
||||
})).toThrow(factoryFailure)
|
||||
expect(failedSignal?.reason).toBe(factoryFailure)
|
||||
|
||||
const effectContext = new Context()
|
||||
const effectService = new SkillService(effectContext)
|
||||
const effectFailure = new Error('effect registration failed')
|
||||
vi.spyOn(effectContext, 'effect').mockImplementation(() => { throw effectFailure })
|
||||
let effectSignal: AbortSignal | undefined
|
||||
expect(() => effectService.registerProvider((control) => {
|
||||
effectSignal = control.signal
|
||||
return {
|
||||
name: 'effect-provider',
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
}
|
||||
})).toThrow(effectFailure)
|
||||
expect(effectSignal?.reason).toBe(effectFailure)
|
||||
|
||||
disposeMemory()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
|
||||
@@ -111,7 +143,7 @@ describe('SkillService registry', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const badDescription = { value: 'object-description' }
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'bad-candidate',
|
||||
list: () => Promise.resolve([{
|
||||
...memorySkill('bad-candidate', 'placeholder', 1),
|
||||
@@ -125,7 +157,7 @@ describe('SkillService registry', () => {
|
||||
|
||||
const badBoolean = new Context()
|
||||
await badBoolean.plugin(SkillService)
|
||||
badBoolean.skills.registerProvider({
|
||||
registerProvider(badBoolean, {
|
||||
name: 'bad-boolean',
|
||||
list: () => Promise.resolve([{
|
||||
...memorySkill('bad-boolean', 'Bad boolean', 1),
|
||||
@@ -140,7 +172,7 @@ describe('SkillService registry', () => {
|
||||
it('rejects non-array provider results and every malformed candidate scalar', async () => {
|
||||
const badList = new Context()
|
||||
await badList.plugin(SkillService)
|
||||
badList.skills.registerProvider({
|
||||
registerProvider(badList, {
|
||||
name: 'non-array-list',
|
||||
list: () => Promise.resolve({} as unknown as SkillCandidate[]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
@@ -171,7 +203,7 @@ describe('SkillService registry', () => {
|
||||
path: '/skills/candidate/SKILL.md',
|
||||
...patch,
|
||||
} as SkillCandidate
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: providerName,
|
||||
list: () => Promise.resolve([candidate]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
@@ -195,7 +227,7 @@ describe('SkillService registry', () => {
|
||||
rank: 1,
|
||||
locator: 'skill-a',
|
||||
}
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'contextual',
|
||||
async list(received) {
|
||||
listedWith = received
|
||||
@@ -218,7 +250,7 @@ describe('SkillService registry', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let getCalls = 0
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'cached',
|
||||
async list() {
|
||||
return [{
|
||||
@@ -267,7 +299,7 @@ describe('SkillService registry', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'held',
|
||||
async list() {
|
||||
return [{
|
||||
@@ -347,7 +379,7 @@ describe('SkillService registry', () => {
|
||||
}
|
||||
let listCalls = 0
|
||||
let received: SkillCandidate | undefined
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'detached',
|
||||
async list() {
|
||||
listCalls += 1
|
||||
@@ -422,7 +454,7 @@ describe('SkillService registry', () => {
|
||||
await ctx.plugin(SkillService)
|
||||
const providerName = `definition-provider-${index}`
|
||||
const skillName = `definition-${index}`
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: providerName,
|
||||
list: () => Promise.resolve([{
|
||||
name: skillName,
|
||||
@@ -455,7 +487,7 @@ describe('SkillService registry', () => {
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'bad',
|
||||
async list() {
|
||||
return [memorySkill('Bad_Name', 'bad', 1)]
|
||||
@@ -474,7 +506,7 @@ describe('SkillService registry', () => {
|
||||
for (const candidate of invalidCandidates) {
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(SkillService)
|
||||
invalid.skills.registerProvider({
|
||||
registerProvider(invalid, {
|
||||
name: candidate.name,
|
||||
async list() {
|
||||
return [candidate]
|
||||
@@ -492,7 +524,7 @@ describe('SkillService registry', () => {
|
||||
it('sorts model-visible summaries without locale-sensitive collation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider(new MemoryProvider([
|
||||
registerProvider(ctx, new MemoryProvider([
|
||||
memorySkill('z-skill', 'Z skill', 10),
|
||||
memorySkill('a-skill', 'A skill', 10),
|
||||
]))
|
||||
@@ -517,7 +549,7 @@ describe('SkillService registry', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 })
|
||||
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
|
||||
ctx.skills.registerProvider(provider)
|
||||
registerProvider(ctx, provider)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
|
||||
provider.replace([memorySkill('second-skill', 'Second', 10)])
|
||||
@@ -544,7 +576,7 @@ describe('SkillService registry', () => {
|
||||
|
||||
let fail = true
|
||||
let flakyCalls = 0
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'flaky',
|
||||
async list() {
|
||||
flakyCalls += 1
|
||||
@@ -572,21 +604,27 @@ describe('SkillService registry', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
|
||||
const dispose = ctx.skills.registerProvider(provider)
|
||||
let invalidate = (): void => {}
|
||||
let signal: AbortSignal | undefined
|
||||
const dispose = ctx.skills.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
signal = control.signal
|
||||
return provider
|
||||
})
|
||||
|
||||
expect((await ctx.skills.snapshot()).complete).toBe(true)
|
||||
provider.replace([memorySkill('second-skill', 'Second', 10)])
|
||||
ctx.skills.invalidateProvider(new MemoryProvider([]))
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
|
||||
|
||||
ctx.skills.invalidateProvider(provider)
|
||||
invalidate()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
|
||||
dispose()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
|
||||
const replacement = new MemoryProvider([memorySkill('replacement-skill', 'Replacement', 10)])
|
||||
ctx.skills.registerProvider(replacement)
|
||||
registerProvider(ctx, replacement)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
|
||||
ctx.skills.invalidateProvider(provider)
|
||||
invalidate()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['replacement-skill'])
|
||||
expect(replacement.listCalls).toBe(1)
|
||||
})
|
||||
@@ -598,11 +636,13 @@ describe('SkillService registry', () => {
|
||||
let changes = 0
|
||||
ctx.on('skills/change', () => { changes += 1 })
|
||||
|
||||
const disposeProvider = ctx.skills.registerProvider(provider)
|
||||
let invalidate = (): void => {}
|
||||
const disposeProvider = ctx.skills.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
return provider
|
||||
})
|
||||
expect(changes).toBe(1)
|
||||
ctx.skills.invalidateProvider(new MemoryProvider([]))
|
||||
expect(changes).toBe(1)
|
||||
ctx.skills.invalidateProvider(provider)
|
||||
invalidate()
|
||||
expect(changes).toBe(2)
|
||||
|
||||
const disposeRuntime = ctx.skills.register({
|
||||
@@ -616,7 +656,7 @@ describe('SkillService registry', () => {
|
||||
expect(changes).toBe(4)
|
||||
disposeProvider()
|
||||
expect(changes).toBe(5)
|
||||
ctx.skills.invalidateProvider(provider)
|
||||
invalidate()
|
||||
expect(changes).toBe(5)
|
||||
})
|
||||
|
||||
@@ -632,7 +672,7 @@ describe('SkillService registry', () => {
|
||||
const disposeObserver = ctx.on('skills/change', () => { observed += 1 })
|
||||
|
||||
const provider = new MemoryProvider([])
|
||||
expect(() => ctx.skills.registerProvider(provider)).not.toThrow()
|
||||
expect(() => registerProvider(ctx, provider)).not.toThrow()
|
||||
await Promise.resolve()
|
||||
expect(observed).toBe(1)
|
||||
expect(warnings).toEqual([
|
||||
@@ -662,12 +702,16 @@ describe('SkillService registry', () => {
|
||||
}
|
||||
return await originalList(options)
|
||||
}
|
||||
ctx.skills.registerProvider(provider)
|
||||
let invalidate = (): void => {}
|
||||
ctx.skills.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
return provider
|
||||
})
|
||||
|
||||
const pending = ctx.skills.list()
|
||||
await started.promise
|
||||
provider.replace([memorySkill('fresh-skill', 'Fresh', 10)])
|
||||
ctx.skills.invalidateProvider(provider)
|
||||
invalidate()
|
||||
release?.()
|
||||
|
||||
expect((await pending).map(skill => skill.name)).toEqual(['fresh-skill'])
|
||||
@@ -695,7 +739,7 @@ describe('SkillService registry', () => {
|
||||
return { ...candidate, name: 'new-name', content: 'Fresh body.' }
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider(provider)
|
||||
registerProvider(ctx, provider)
|
||||
|
||||
expect(await ctx.skills.get('old-name')).toBeUndefined()
|
||||
await ctx.skills.list()
|
||||
@@ -705,7 +749,7 @@ describe('SkillService registry', () => {
|
||||
it('returns undefined when a discovered candidate disappears before loading', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'vanished-body',
|
||||
async list() {
|
||||
return [{ ...memorySkill('vanished-skill', 'Vanished', 10), provider: 'vanished-body' }]
|
||||
@@ -728,7 +772,7 @@ describe('SkillService registry', () => {
|
||||
throw new Error('provider failure coercion failed')
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'hostile-failure',
|
||||
list() {
|
||||
// Deliberately violate the provider contract to prove containment is total.
|
||||
@@ -753,7 +797,7 @@ describe('SkillService registry', () => {
|
||||
let release: (() => void) | undefined
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const dispose = ctx.skills.registerProvider({
|
||||
const dispose = registerProvider(ctx, {
|
||||
name: 'delayed',
|
||||
async list() {
|
||||
markStarted?.()
|
||||
@@ -783,7 +827,7 @@ describe('SkillService registry', () => {
|
||||
const held = new Promise<SkillCandidate[]>((resolve) => {
|
||||
release = () => { resolve([]) }
|
||||
})
|
||||
ctx.skills.registerProvider({
|
||||
registerProvider(ctx, {
|
||||
name: 'uncooperative',
|
||||
list(options) {
|
||||
seenSignal = options.signal
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('dsh-tool-skill', () => {
|
||||
const home = await tempDir('tool-prefix-signal')
|
||||
const ctx = await setup(home)
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.skills.registerProvider({
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'signal-probe',
|
||||
async list(options) {
|
||||
seenSignal = options.signal
|
||||
@@ -162,7 +162,7 @@ describe('dsh-tool-skill', () => {
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
}))
|
||||
const controller = new AbortController()
|
||||
|
||||
await composePrefix(ctx, '/workspace', controller.signal)
|
||||
@@ -245,7 +245,11 @@ describe('dsh-tool-skill', () => {
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider(provider)
|
||||
let invalidate = (): void => {}
|
||||
ctx.skills.registerProvider((control) => {
|
||||
invalidate = control.invalidate
|
||||
return provider
|
||||
})
|
||||
const session = new Session(SessionId('incomplete-prefix'))
|
||||
const agent = sessionAgent(session)
|
||||
openMessageTurn(session)
|
||||
@@ -253,7 +257,7 @@ describe('dsh-tool-skill', () => {
|
||||
await composePrefixForAgent(ctx, agent)
|
||||
expect(catalogMessages(session)).toEqual([])
|
||||
failing = false
|
||||
ctx.skills.invalidateProvider(provider)
|
||||
invalidate()
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
|
||||
expect(catalogMessages(session)).toEqual([])
|
||||
@@ -421,7 +425,7 @@ describe('dsh-tool-skill', () => {
|
||||
openMessageTurn(session)
|
||||
expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('stable-skill')
|
||||
|
||||
ctx.skills.registerProvider({
|
||||
ctx.skills.registerProvider(() => ({
|
||||
name: 'failing',
|
||||
async list() {
|
||||
throw new Error('temporarily unavailable')
|
||||
@@ -429,7 +433,7 @@ describe('dsh-tool-skill', () => {
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
}))
|
||||
disposeStable()
|
||||
await fireStep(ctx, agent, 1, 1)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user