refactor(core): simplify tools prompts and trusted services

This commit is contained in:
Tianyi Cui
2026-07-12 22:39:01 +08:00
parent 28e04ff4fb
commit 02ca71db57
24 changed files with 636 additions and 2695 deletions

View File

@@ -8,28 +8,28 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
### Public API
- `ctx.skills.registerProvider(provider): () => Promise<void> | void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry snapshots the name and callback identities at registration, so replacing those fields later cannot change lookup or HMR cleanup; callbacks remain bound to the original provider object and can still read its mutable state. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
- `ctx.skills.list({ cwd?, signal? })` Snapshots the lookup options, then returns detached model-invocable summaries for the current workspace, merged across providers and sorted by name.
- `ctx.skills.get(name, { cwd?, signal? })` Uses one lookup-options snapshot to select and load the winner, rechecks cancellation after discovery or a cache hit, races provider loading against the same signal, then returns a detached full definition, including disabled-for-model skills.
- `ctx.skills.register(skill): () => Promise<void> | void` Registers a detached runtime embedded skill. 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.
- `ctx.skills.registerProvider(provider): () => Promise<void> | 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.register(skill): () => Promise<void> | 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
| Field | Default | Meaning |
|---|---|---|
| `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalog snapshots kept in memory. |
| `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalogs kept in memory. |
## Provider Contract
A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Registration copies `name` and binds the current `list` and `get` methods once; replacing those fields on the caller-owned object later does not rewrite the live registry entry, and disposal always removes the original name. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting uncooperative discovery and loading work so agent cancellation cannot hang prefix composition or skill loading.
A provider registers synchronously from its `apply()` and returns `readonly SkillCandidate[]` from `list(options)` when discovery is requested. The provider, lookup options, candidates, and loaded definitions are readonly same-process contracts: the registry borrows them rather than cloning, freezing, or rebinding callbacks. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting uncooperative discovery and loading work so agent cancellation cannot hang prefix composition or skill loading.
Each public lookup captures `cwd` and the abort-signal identity once before cache or provider work, and providers receive that frozen lookup record. The registry reads each returned candidate once, validates that snapshot, and detaches its resource metadata before caching it. The winning provider receives another detached candidate in `get(candidate, options)`, while `candidate.locator` preserves the exact provider-owned identity originally returned by `list()`; a local provider can therefore use a file-path handle while a remote provider can use a URL, id, or version token. A loaded definition is detached again before it reaches the caller.
The registry validates parsed provider candidates before caching them and validates loaded definitions before returning them. The winning provider receives the exact candidate and opaque `locator` identity it returned from `list()`; a local provider can therefore use a file-path handle while a remote provider can use a URL, id, or version token. Callers and providers must honor the readonly contract after handing values to the registry.
The registry validates fixed provider, candidate, runtime-registration, and loaded-definition fields before detachment: names/descriptions/content use their declared string types, ranks are finite numbers, and `disableModelInvocation` is boolean when present. Caller-owned objects masquerading as scalars are rejected without being frozen. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed, registry-owned catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers.
Parsed candidate and loaded-definition fields are validated at the provider boundary: names/descriptions/content use their declared string types, ranks are finite numbers, and `disableModelInvocation` is boolean when present. Candidate contract violations fail fast because the provider or its parser is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers.
## Runtime Skills
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration detaches the accepted definition and nested resource metadata; later mutation of the registration object or a returned list/get value cannot rewrite the live skill. Registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime definitions and nested resource metadata are borrowed readonly; the service only materializes the top-level definition needed to supply the default `provider`. Registration is first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
## Consumer boundary

View File

@@ -32,56 +32,56 @@ export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-d
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
export type SkillResourceBase =
| { kind: 'directory'; path: string }
| { kind: 'url'; url: string }
| { kind: 'opaque'; description: string }
| { readonly kind: 'directory'; readonly path: string }
| { 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. */
name: string
readonly name: string
/** Short routing description shown to the model. */
description: string
readonly description: string
/** Optional extra routing guidance shown to the model. */
whenToUse?: string
readonly whenToUse?: string
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
disableModelInvocation?: boolean
readonly disableModelInvocation?: boolean
/** Discovery source that produced this winning skill. */
source: SkillSource
readonly source: SkillSource
/** Provider that owns this skill body. */
provider: string
readonly provider: string
/** Provider-specific base for relative resources. */
resourceBase?: SkillResourceBase
readonly resourceBase?: SkillResourceBase
}
/** Provider catalog entry used by the registry to merge and later load skills. */
export interface SkillCandidate extends SkillSummary {
/** Lower ranks win duplicate skill names before provider registration order is considered. */
rank: number
readonly rank: number
/** Opaque provider-owned handle passed back to `provider.get()`. */
locator: unknown
readonly locator: unknown
/** Absolute file path when the provider has one. */
path?: string
readonly path?: string
/** Parsed optional metadata object from provider-specific skill frontmatter. */
metadata?: Record<string, unknown>
readonly metadata?: Readonly<Record<string, unknown>>
}
/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */
export interface SkillDefinition extends SkillSummary {
/** Markdown instruction body after any provider-specific metadata removal. */
content: string
readonly content: string
/** Absolute file path when the skill came from disk. */
path?: string
readonly path?: string
/** Parsed optional metadata object from frontmatter. */
metadata?: Record<string, unknown>
readonly metadata?: Readonly<Record<string, unknown>>
}
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { provider?: string }
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string }
/** Caller context used for cwd-sensitive and abortable provider work. */
export interface SkillLookupOptions {
/** Workspace selector captured at lookup entry; providers receive a read-only snapshot. */
/** Workspace selector for the current lookup. */
readonly cwd?: string | undefined
/** Abort discovery or loading work for the current caller. */
readonly signal?: AbortSignal | undefined
@@ -90,7 +90,7 @@ export interface SkillLookupOptions {
/** 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. */
name: string
readonly name: string
/**
* List available skill candidates for the current lookup context. Provider
* plugins register synchronously during `apply()`; remote initialization,
@@ -99,21 +99,20 @@ export interface SkillProvider {
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns provider candidates with precedence ranks and opaque locators.
*/
list(options: SkillLookupOptions): Promise<SkillCandidate[]>
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
/**
* Load a complete skill body for a previously listed candidate.
* @param candidate - a detached snapshot of the winning candidate; its opaque
* `locator` retains the exact identity originally returned by this provider.
* @param candidate - the winning candidate originally returned by this provider.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns the full skill body, or `undefined` if it is no longer loadable.
*/
get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
}
/** Skill registry configuration. */
export interface Config {
/** Maximum number of completed cwd/provider catalog snapshots kept in memory. */
collectCacheMaxEntries?: number
/** Maximum number of completed cwd/provider catalogs kept in memory. */
readonly collectCacheMaxEntries?: number
}
declare module 'cordis' {
@@ -163,7 +162,7 @@ export class SkillService extends Service {
private readonly collectCacheMaxEntries: number
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
private readonly runtime = new Map<string, SkillDefinition>()
private readonly runtime = new Map<string, SkillRegistration>()
private readonly collectCache = new Map<string, IndexedCandidate[]>()
private providerRevision = 0
private nextProviderOrder = 0
@@ -179,53 +178,38 @@ export class SkillService extends Service {
* Register a skill provider synchronously during the provider plugin's
* `apply()`. Throws if another provider already owns the same provider name,
* including the reserved runtime provider name. Providers that need remote
* initialization do that work inside `list()` after registration. The name
* and callback identities are snapshotted at registration, so later
* replacement of those fields cannot change the registry key, dispatch
* callbacks, or HMR cleanup identity. Bound callbacks retain the original
* provider object as their receiver, so provider-owned mutable state remains
* live. Effect-scoped and HMR-safe: disposing the caller's fiber unregisters
* the provider and invalidates cached catalogs.
* initialization do that work inside `list()` after registration. Providers
* are readonly same-process registrations: the registry borrows the provider
* object and invokes its methods directly. Effect-scoped and HMR-safe:
* disposing the caller's fiber unregisters the provider and invalidates
* cached catalogs.
* @param provider - the provider to register by `provider.name`.
* @returns the exact Cordis effect disposer that unregisters this provider;
* composite effects may yield it directly to preserve teardown ordering.
*/
registerProvider(provider: SkillProvider): () => Promise<void> | void {
// Snapshot the registration contract before entering the effect. The
// callback binding preserves the historical method receiver while making
// replacement of `provider.list`/`provider.get` after registration inert.
// In particular, cleanup must never re-read caller-owned `provider.name`:
// an HMR host may mutate or reuse that object before its old fiber unloads.
const name = provider.name
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputList = provider.list
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputGet = provider.get
if (typeof name !== 'string') throw new TypeError('skill provider name must be a string')
if (typeof inputList !== 'function') throw new TypeError(`skill provider "${name}" list must be a function`)
if (typeof inputGet !== 'function') throw new TypeError(`skill provider "${name}" get must be a function`)
const snapshot: SkillProvider = Object.freeze({
name,
list: inputList.bind(provider),
get: inputGet.bind(provider),
})
const dispose = this.ctx.effect(function* (this: SkillService) {
if (snapshot.name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
if (this.providers.has(snapshot.name)) {
throw new Error(`a skill provider named "${snapshot.name}" is already registered`)
}
this.providers.set(snapshot.name, { provider: snapshot, order: this.nextProviderOrder })
this.nextProviderOrder += 1
this.invalidateCache()
if (name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
if (this.providers.has(name)) {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const ctx = this.ctx
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = ctx.effect(function* () {
providers.set(name, { provider, order })
invalidateCache()
yield () => {
this.providers.delete(snapshot.name)
this.invalidateCache()
this.ctx.emit('skill/provider-removed', snapshot.name)
providers.delete(name)
invalidateCache()
ctx.emit('skill/provider-removed', name)
}
this.ctx.emit('skill/provider-added', snapshot)
}.bind(this), 'skills.registerProvider()')
ctx.emit('skill/provider-added', provider)
}, 'skills.registerProvider()')
return dispose
}
@@ -233,44 +217,46 @@ export class SkillService extends Service {
* Register a runtime skill contribution. Runtime registrations are treated as
* embedded provider entries with project-over-user priority. Same-name runtime
* registrations are first-wins: a duplicate logs a warning and gets a no-op
* disposer so it cannot remove the active contribution. The registry detaches
* the accepted definition, including nested resource metadata, so later caller
* mutation cannot rewrite the live contribution.
* disposer so it cannot remove the active contribution. Runtime definitions
* are readonly same-process registrations; the registry borrows their nested
* resource metadata.
* @param skill - the complete skill definition to expose for discovery.
* @returns the exact Cordis effect disposer that removes this runtime
* contribution and invalidates caches; composite effects may yield it
* directly to preserve teardown ordering.
*/
register(skill: SkillRegistration): () => Promise<void> | void {
const normalized = normalizeRuntimeSkill(skill)
const existing = this.runtime.get(normalized.name)
validateRuntimeSkill(skill)
const existing = this.runtime.get(skill.name)
if (existing !== undefined) {
this.ctx.logger.warn(`runtime skill "${normalized.name}" ignored because it is already registered`)
this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`)
return () => {}
}
const dispose = this.ctx.effect(function* (this: SkillService) {
this.runtime.set(normalized.name, normalized)
this.runtimeRevision += 1
this.invalidateCache()
const runtime = this.runtime
const updateRevision = (): void => { this.runtimeRevision += 1 }
const invalidateCache = (): void => { this.invalidateCache() }
const dispose = this.ctx.effect(function* () {
runtime.set(skill.name, skill)
updateRevision()
invalidateCache()
yield () => {
this.runtime.delete(normalized.name)
this.runtimeRevision += 1
this.invalidateCache()
runtime.delete(skill.name)
updateRevision()
invalidateCache()
}
}.bind(this), 'skills.register()')
}, 'skills.register()')
return dispose
}
/**
* List model-invocable skill summaries for a workspace. The lookup options are
* snapshotted before discovery, and every returned summary is detached from the
* cached provider catalog.
* List model-invocable skill summaries for a workspace. 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.
*/
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
const accepted = snapshotLookupOptions(options)
return (await this.collect(accepted))
return (await this.collect(options))
.map(entry => entry.candidate)
.filter(skill => skill.disableModelInvocation !== true)
.map(toSummary)
@@ -278,10 +264,10 @@ export class SkillService extends Service {
}
/**
* Load one full skill definition by name. One lookup-options snapshot selects
* and loads the winner; the provider receives detached candidate metadata with
* its opaque locator identity preserved, and the returned definition is also
* detached from provider-owned data. Cancellation is rechecked after catalog
* Load one full skill definition by name. The provider receives the winning
* candidate it returned during discovery, including its opaque locator, and
* the registry returns the provider's definition after validating it.
* Cancellation is rechecked after catalog
* selection (including a cache hit), and provider loading is raced against the
* same signal so an uncooperative provider cannot hang the caller.
* @param name - kebab-case skill name.
@@ -290,16 +276,17 @@ export class SkillService extends Service {
*/
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
if (!isSkillName(name)) return undefined
const accepted = snapshotLookupOptions(options)
const collected = await this.collect(accepted)
throwIfAborted(accepted.signal)
const collected = await this.collect(options)
throwIfAborted(options.signal)
const match = collected.find(entry => entry.candidate.name === name)
if (match === undefined) return undefined
const definition = await waitWithAbort(
match.provider.get(copyCandidate(match.candidate), accepted),
accepted.signal,
match.provider.get(match.candidate, options),
options.signal,
)
return definition === undefined ? undefined : snapshotDefinition(definition)
if (definition === undefined) return undefined
validateDefinition(definition)
return definition
}
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
@@ -358,21 +345,22 @@ export class SkillService extends Service {
}
for (const { provider, order } of [...this.providers.values()]) {
let localOrder = 0
let listed: SkillCandidate[] | undefined
let output: unknown
try {
listed = await waitWithAbort(provider.list(options), options.signal)
output = await waitWithAbort(provider.list(options), options.signal)
} catch (error) {
if (options.signal?.aborted === true) throw toError(options.signal.reason)
cacheable = false
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
}
if (listed === undefined) continue
if (!Array.isArray(listed)) {
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 snapshot = snapshotCandidate(candidate, provider.name)
candidates.push({ candidate: snapshot, provider, providerOrder: order, localOrder })
validateCandidate(candidate, provider.name)
candidates.push({ candidate, provider, providerOrder: order, localOrder })
localOrder += 1
}
}
@@ -392,14 +380,20 @@ const RUNTIME_SKILL_PROVIDER: SkillProvider = {
return Promise.resolve([])
},
get(candidate) {
const skill = candidate.locator as SkillDefinition
return Promise.resolve({ ...skill })
const skill = candidate.locator as SkillRegistration
return Promise.resolve({ ...skill, provider: skill.provider ?? RUNTIME_PROVIDER })
},
}
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
function runtimeCandidate(skill: SkillRegistration): SkillCandidate {
return {
...toSummary(skill),
name: skill.name,
description: skill.description,
...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},
...skill.disableModelInvocation !== undefined ? { disableModelInvocation: skill.disableModelInvocation } : {},
source: skill.source,
provider: skill.provider ?? RUNTIME_PROVIDER,
...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {},
rank: RUNTIME_RANK,
locator: skill,
...skill.path !== undefined ? { path: skill.path } : {},
@@ -407,50 +401,6 @@ function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
}
}
/** Read provider candidate data once and detach it while preserving its opaque locator identity. */
function copyCandidate(candidate: SkillCandidate, providerName?: string): SkillCandidate {
const name = candidate.name
const description = candidate.description
const whenToUse = candidate.whenToUse
const disableModelInvocation = candidate.disableModelInvocation
const source = candidate.source
const provider = candidate.provider
const resourceBase = candidate.resourceBase
const rank = candidate.rank
const locator = candidate.locator
const path = candidate.path
const metadata = candidate.metadata
const accepted: SkillCandidate = {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
source,
provider,
...resourceBase !== undefined ? { resourceBase } : {},
rank,
// `locator` is the one deliberately provider-owned capability in a
// candidate. Its exact identity must round-trip back to provider.get().
locator,
...path !== undefined ? { path } : {},
...metadata !== undefined ? { metadata } : {},
}
// Validate the exact scalar snapshot before cloning nested data. This keeps a
// malformed candidate's provider-contract error from being masked by an
// unrelated DataCloneError in its metadata.
if (providerName !== undefined) validateCandidate(accepted, providerName)
return {
...accepted,
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
...metadata !== undefined ? { metadata: structuredClone(metadata) } : {},
}
}
/** Normalize one provider result into the registry-owned catalog snapshot. */
function snapshotCandidate(candidate: SkillCandidate, providerName: string): SkillCandidate {
return copyCandidate(candidate, providerName)
}
function validateCandidate(candidate: SkillCandidate, providerName: string): void {
if (typeof candidate.name !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`)
@@ -487,58 +437,21 @@ function validateCandidate(candidate: SkillCandidate, providerName: string): voi
}
}
function normalizeRuntimeSkill(skill: SkillRegistration): SkillDefinition {
// Read every caller-owned top-level field once so validation and storage use
// one coherent definition even when JavaScript accessors are involved.
const name = skill.name
const description = skill.description
const whenToUse = skill.whenToUse
const disableModelInvocation = skill.disableModelInvocation
const source = skill.source
const inputProvider = skill.provider
const provider = inputProvider === undefined ? RUNTIME_PROVIDER : inputProvider
const resourceBase = skill.resourceBase
const content = skill.content
const path = skill.path
const metadata = skill.metadata
if (typeof name !== 'string') throw new TypeError('runtime skill name must be a string')
if (!SKILL_NAME.test(name)) throw new Error(`invalid skill name "${name}"`)
if (typeof description !== 'string') throw new TypeError(`skill "${name}" description must be a string`)
if (description.length === 0) throw new Error(`skill "${name}" requires a description`)
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
throw new TypeError(`skill "${name}" disableModelInvocation must be a boolean`)
}
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`skill "${name}" whenToUse must be a string`)
if (typeof source !== 'string') throw new TypeError(`skill "${name}" source must be a string`)
if (typeof provider !== 'string') throw new TypeError(`skill "${name}" provider must be a string`)
if (typeof content !== 'string') throw new TypeError(`skill "${name}" content must be a string`)
if (path !== undefined && typeof path !== 'string') throw new TypeError(`skill "${name}" path must be a string`)
return {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
source,
provider,
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
content,
...path !== undefined ? { path } : {},
...metadata !== undefined ? { metadata: structuredClone(metadata) } : {},
}
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`)
}
/** Detach a provider-loaded definition before it crosses back to the caller. */
function snapshotDefinition(skill: SkillDefinition): SkillDefinition {
/** Validate a definition loaded from a provider-controlled parser or remote source. */
function validateDefinition(skill: SkillDefinition): void {
const name = skill.name
const description = skill.description
const whenToUse = skill.whenToUse
const disableModelInvocation = skill.disableModelInvocation
const source = skill.source
const provider = skill.provider
const resourceBase = skill.resourceBase
const content = skill.content
const path = skill.path
const metadata = skill.metadata
if (typeof name !== 'string') throw new TypeError('loaded skill name must be a string')
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`)
@@ -551,18 +464,6 @@ function snapshotDefinition(skill: SkillDefinition): SkillDefinition {
if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)
if (typeof content !== 'string') throw new TypeError(`loaded skill "${name}" content must be a string`)
if (path !== undefined && typeof path !== 'string') throw new TypeError(`loaded skill "${name}" path must be a string`)
return {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
source,
provider,
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
content,
...path !== undefined ? { path } : {},
...metadata !== undefined ? { metadata: structuredClone(metadata) } : {},
}
}
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
@@ -574,7 +475,7 @@ function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
source,
provider,
...resourceBase !== undefined ? { resourceBase: structuredClone(resourceBase) } : {},
...resourceBase !== undefined ? { resourceBase } : {},
}
}
@@ -604,16 +505,6 @@ function collectCacheKey(options: SkillLookupOptions, providerRevision: number,
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
}
/** Capture one lookup identity before any provider or cache async boundary. */
function snapshotLookupOptions(options: SkillLookupOptions): Readonly<SkillLookupOptions> {
const cwd = options.cwd
const signal = options.signal
return Object.freeze({
...cwd !== undefined ? { cwd } : {},
...signal !== undefined ? { signal } : {},
})
}
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return promise
throwIfAborted(signal)

View File

@@ -107,85 +107,9 @@ describe('SkillService registry', () => {
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
})
it('snapshots a provider registration so caller mutation cannot corrupt HMR cleanup', async () => {
it('validates parsed candidate fields', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const candidate: SkillCandidate = {
name: 'stable-skill',
description: 'Stable skill',
provider: 'stable-provider',
source: 'test',
rank: 1,
locator: 'original',
}
const originalList = vi.fn(() => Promise.resolve([candidate]))
const originalGet = vi.fn((listed: SkillCandidate) => Promise.resolve<SkillDefinition>({
...listed,
content: 'Original body.',
}))
const provider: SkillProvider = {
name: 'stable-provider',
list: originalList,
get: originalGet,
}
const added: SkillProvider[] = []
const removed: string[] = []
ctx.on('skill/provider-added', (registered) => { added.push(registered) })
ctx.on('skill/provider-removed', (name) => { removed.push(name) })
const owner = await ctx.plugin({
name: 'mutable-provider-owner',
inject: ['skills'],
apply(pluginCtx: Context) {
pluginCtx.skills.registerProvider(provider)
},
})
provider.name = 'mutated-provider'
const replacementList = vi.fn(() => Promise.resolve([]))
const replacementGet = vi.fn(() => Promise.resolve(undefined))
provider.list = replacementList
provider.get = replacementGet
expect(added).toHaveLength(1)
expect(added[0]).not.toBe(provider)
expect(added[0]?.name).toBe('stable-provider')
expect(Object.isFrozen(added[0])).toBe(true)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['stable-skill'])
expect((await ctx.skills.get('stable-skill'))?.content).toBe('Original body.')
expect(originalList).toHaveBeenCalledOnce()
expect(originalGet).toHaveBeenCalledOnce()
expect(replacementList).not.toHaveBeenCalled()
expect(replacementGet).not.toHaveBeenCalled()
await owner.dispose()
expect(removed).toEqual(['stable-provider'])
expect(await ctx.skills.list()).toEqual([])
const replacement = new MemoryProvider([])
Object.defineProperty(replacement, 'name', { value: 'stable-provider' })
expect(() => ctx.skills.registerProvider(replacement)).not.toThrow()
})
it('rejects malformed provider and candidate scalar fields without freezing caller objects', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const badProviderName = { value: 'object-provider' }
expect(() => ctx.skills.registerProvider({
name: badProviderName as unknown as string,
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
})).toThrow('skill provider name must be a string')
expect(Object.isFrozen(badProviderName)).toBe(false)
expect(() => ctx.skills.registerProvider({
name: 'bad-list',
list: { bind() {} } as unknown as SkillProvider['list'],
get: () => Promise.resolve(undefined),
})).toThrow('list must be a function')
expect(() => ctx.skills.registerProvider({
name: 'bad-get',
list: () => Promise.resolve([]),
get: { bind() {} } as unknown as SkillProvider['get'],
})).toThrow('get must be a function')
const badDescription = { value: 'object-description' }
ctx.skills.registerProvider({
name: 'bad-candidate',
@@ -198,7 +122,6 @@ describe('SkillService registry', () => {
get: () => Promise.resolve(undefined),
})
await expect(ctx.skills.list()).rejects.toThrow('non-string description')
expect(Object.isFrozen(badDescription)).toBe(false)
const badBoolean = new Context()
await badBoolean.plugin(SkillService)
@@ -258,46 +181,37 @@ describe('SkillService registry', () => {
}
})
it('snapshots lookup options before asynchronous discovery and loading', async () => {
it('borrows the exact lookup options through discovery and loading', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
let release: (() => void) | undefined
const gate = new Promise<void>((resolve) => { release = resolve })
const listCwds: (string | undefined)[] = []
const getCwds: (string | undefined)[] = []
const options: SkillLookupOptions = { cwd: '/workspace/a' }
let listedWith: SkillLookupOptions | undefined
let loadedWith: SkillLookupOptions | undefined
const candidate: SkillCandidate = {
name: 'skill-a',
description: 'Skill A',
provider: 'contextual',
source: 'test',
rank: 1,
locator: 'skill-a',
}
ctx.skills.registerProvider({
name: 'contextual',
async list(options) {
listCwds.push(options.cwd)
await gate
const name = options.cwd === '/workspace/a' ? 'skill-a' : 'skill-b'
return [
{ name, description: name, provider: 'contextual', source: 'test', rank: 1, locator: name },
{ name: 'vanished', description: 'Vanished', provider: 'contextual', source: 'test', rank: 2, locator: 'vanished' },
]
async list(received) {
listedWith = received
return [candidate]
},
async get(candidate, options) {
getCwds.push(options.cwd)
if (candidate.name === 'vanished') return undefined
return { ...candidate, content: `${options.cwd}:${candidate.name}` }
async get(received, lookup) {
expect(received).toBe(candidate)
loadedWith = lookup
return { ...received, content: 'Skill A body.' }
},
})
const listOptions: { cwd: string | undefined } = { cwd: '/workspace/a' }
const pending = ctx.skills.list(listOptions)
listOptions.cwd = '/workspace/b'
release?.()
expect((await pending).map(skill => skill.name)).toEqual(['skill-a', 'vanished'])
expect((await ctx.skills.list({ cwd: '/workspace/a' })).map(skill => skill.name)).toEqual(['skill-a', 'vanished'])
expect(listCwds).toEqual(['/workspace/a'])
const getOptions: { cwd: string | undefined } = { cwd: '/workspace/a' }
const loading = ctx.skills.get('skill-a', getOptions)
getOptions.cwd = '/workspace/b'
expect((await loading)?.content).toBe('/workspace/a:skill-a')
expect(await ctx.skills.get('vanished', { cwd: '/workspace/a' })).toBeUndefined()
expect(getCwds).toEqual(['/workspace/a', '/workspace/a'])
expect((await ctx.skills.list(options)).map(skill => skill.name)).toEqual(['skill-a'])
expect(await ctx.skills.get('skill-a', options)).toMatchObject({ content: 'Skill A body.' })
expect(listedWith).toBe(options)
expect(loadedWith).toBe(options)
})
it('rechecks cancellation after cached discovery before provider loading', async () => {
@@ -402,7 +316,7 @@ describe('SkillService registry', () => {
expect(settled).toBe('aborted')
})
it('detaches cached candidates and loaded definitions while preserving locator identity', async () => {
it('borrows cached candidates and loaded definitions from the provider', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const locator = { id: 'provider-owned' }
@@ -445,50 +359,27 @@ describe('SkillService registry', () => {
},
})
const first = await ctx.skills.list()
candidate.name = 'Bad_Name'
candidate.description = ''
if (candidate.resourceBase?.kind === 'opaque') candidate.resourceBase.description = 'mutated candidate'
if (candidate.metadata) candidate.metadata.owner = 'mutated candidate'
if (first[0]?.resourceBase?.kind === 'opaque') first[0].resourceBase.description = 'mutated summary'
const second = await ctx.skills.list()
expect(second).toEqual([expect.objectContaining({
const listed = await ctx.skills.list()
expect(listed).toEqual([expect.objectContaining({
name: 'stable-skill',
description: 'Stable description',
resourceBase: { kind: 'opaque', description: 'candidate resources' },
})])
expect(listed[0]?.resourceBase).toBe(candidate.resourceBase)
expect(listCalls).toBe(1)
const loaded = await ctx.skills.get('stable-skill')
expect(received).not.toBe(candidate)
expect(received).toBe(candidate)
expect(received?.locator).toBe(locator)
expect(received).toMatchObject({
name: 'stable-skill',
description: 'Stable description',
resourceBase: { kind: 'opaque', description: 'candidate resources' },
metadata: { owner: 'candidate' },
})
expect(loaded).not.toBe(definition)
if (loaded?.resourceBase?.kind === 'opaque') loaded.resourceBase.description = 'mutated definition output'
if (loaded?.metadata) loaded.metadata.owner = 'mutated definition output'
expect(await ctx.skills.get('stable-skill')).toMatchObject({
resourceBase: { kind: 'opaque', description: 'definition resources' },
metadata: { owner: 'definition' },
})
expect(definition).toMatchObject({
resourceBase: { kind: 'opaque', description: 'definition resources' },
metadata: { owner: 'definition' },
})
expect(loaded).toBe(definition)
})
it('detaches runtime registrations and every public resource view', async () => {
it('preserves readonly runtime resource identities while adding the default provider', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const resourceBase = { kind: 'opaque' as const, description: 'runtime resources' }
const metadata = { owner: 'runtime' }
ctx.skills.register({
const registration = {
name: 'runtime-skill',
description: 'Runtime',
whenToUse: 'When runtime data is needed.',
@@ -497,101 +388,20 @@ describe('SkillService registry', () => {
resourceBase,
metadata,
content: 'Runtime body.',
})
}
ctx.skills.register(registration)
ctx.skills.register({
name: 'z-runtime',
description: 'Second runtime skill',
source: 'runtime',
content: 'Second runtime body.',
})
resourceBase.description = 'mutated registration'
metadata.owner = 'mutated registration'
const listed = await ctx.skills.list()
const loaded = await ctx.skills.get('runtime-skill')
expect(listed[0]?.resourceBase).toEqual({ kind: 'opaque', description: 'runtime resources' })
expect(loaded?.metadata).toEqual({ owner: 'runtime' })
if (listed[0]?.resourceBase?.kind === 'opaque') listed[0].resourceBase.description = 'mutated list output'
if (loaded?.resourceBase?.kind === 'opaque') loaded.resourceBase.description = 'mutated get output'
if (loaded?.metadata) loaded.metadata.owner = 'mutated get output'
expect((await ctx.skills.list())[0]?.resourceBase).toEqual({ kind: 'opaque', description: 'runtime resources' })
expect(await ctx.skills.get('runtime-skill')).toMatchObject({
resourceBase: { kind: 'opaque', description: 'runtime resources' },
metadata: { owner: 'runtime' },
})
})
it('rejects malformed runtime and loaded-definition scalar fields without freezing them', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const runtimeDescription = { value: 'runtime-description' }
expect(() => ctx.skills.register({
name: 'bad-runtime',
description: runtimeDescription as unknown as string,
source: 'runtime',
content: 'body',
})).toThrow('description must be a string')
expect(Object.isFrozen(runtimeDescription)).toBe(false)
expect(() => ctx.skills.register({
name: 'bad-runtime-boolean',
description: 'Runtime',
disableModelInvocation: 'false' as unknown as boolean,
source: 'runtime',
content: 'body',
})).toThrow('disableModelInvocation must be a boolean')
expect(() => ctx.skills.register({
name: 'bad-runtime-provider',
description: 'Runtime',
source: 'runtime',
provider: null as unknown as string,
content: 'body',
})).toThrow('provider must be a string')
const loadedContent = { value: 'loaded-content' }
ctx.skills.registerProvider({
name: 'bad-definition',
list: () => Promise.resolve([{
name: 'bad-definition',
description: 'Candidate',
provider: 'bad-definition',
source: 'test',
rank: 1,
locator: 'bad-definition',
}]),
get: candidate => Promise.resolve({
...candidate,
content: loadedContent as unknown as string,
}),
})
await expect(ctx.skills.get('bad-definition')).rejects.toThrow('content must be a string')
expect(Object.isFrozen(loadedContent)).toBe(false)
})
it('rejects every other malformed runtime scalar', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
type Registration = Parameters<typeof ctx.skills.register>[0]
const valid: Registration = {
name: 'runtime-validation',
description: 'Runtime validation',
whenToUse: 'Use this runtime skill.',
disableModelInvocation: false,
source: 'runtime',
provider: 'runtime-validation',
content: 'Runtime body.',
path: '/skills/runtime-validation/SKILL.md',
}
const cases: { patch: Partial<Registration>; expected: string }[] = [
{ patch: { name: { value: 'runtime' } as unknown as string }, expected: 'runtime skill name must be a string' },
{ 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: { content: { value: 'content' } as unknown as string }, expected: 'content must be a string' },
{ patch: { path: 1 as unknown as string }, expected: 'path must be a string' },
]
for (const { patch, expected } of cases) {
expect(() => ctx.skills.register({ ...valid, ...patch })).toThrow(expected)
}
expect(listed[0]?.resourceBase).toBe(resourceBase)
expect(loaded?.resourceBase).toBe(resourceBase)
expect(loaded?.metadata).toBe(metadata)
expect(loaded?.provider).toBe('runtime')
})
it('rejects every malformed scalar in provider-loaded definitions', async () => {
@@ -853,39 +663,6 @@ describe('SkillService registry', () => {
expect(settled).toBe('aborted')
})
it('does not miss an abort racing listener installation', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)
const reason = new Error('racing abort')
let aborted = false
const signal = {
get aborted() {
return aborted
},
reason,
throwIfAborted() {
if (aborted) throw reason
},
addEventListener(_type: string, listener: () => void) {
aborted = true
listener()
},
removeEventListener() {},
} as unknown as AbortSignal
ctx.skills.registerProvider({
name: 'racing-abort',
list() {
return Promise.reject(new Error('late provider failure'))
},
async get() {
return undefined
},
})
await expect(ctx.skills.list({ signal })).rejects.toBe(reason)
await Promise.resolve()
})
it('rejects invalid runtime skill registrations and ignores duplicates', async () => {
const ctx = new Context()
await ctx.plugin(SkillService)