Merge remote-tracking branch 'origin/master' into codex/project-instruction-files
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/persistence-catalog.md # docs/rfc/implemented/feature/2026-06-15-code-mode.md # docs/rfc/implemented/feature/2026-06-30-interception-seams.md # docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md # docs/tool-execution-pipeline.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-core/tests/agent-core.spec.ts # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.md # packages/core/session/src/index.ts # packages/core/tools/README.md # packages/core/tools/src/index.ts # pnpm-lock.yaml # scripts/gen-doc-graphs.ts # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -8,26 +8,28 @@ This package owns the `ctx.skills` interface. It does not know whether skills co
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registration is effect-scoped and HMR-safe.
|
||||
- `ctx.skills.list({ cwd?, signal? })` Returns model-invocable skill summaries for the current workspace, merged across providers and sorted by name.
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` Returns the full winning skill, including disabled-for-model skills.
|
||||
- `ctx.skills.register(skill): () => void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer.
|
||||
- `ctx.skills.registerProvider(provider): () => void` Registers a readonly provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registry borrows the provider object and invokes its methods directly. The registration is effect-scoped and HMR-safe, and the exact Cordis disposer supports ordered composite teardown.
|
||||
- `ctx.skills.list({ cwd?, signal? })` Borrows the readonly lookup options, then returns model-invocable summaries for the current workspace, merged across providers and sorted by name.
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` Uses the same readonly options and winning candidate for discovery and loading, rechecks cancellation after discovery or a cache hit, races provider loading against the signal, validates the loaded definition, then returns it, including disabled-for-model skills.
|
||||
- `ctx.skills.register(skill): () => void` Registers a readonly runtime embedded skill, adding `provider: "runtime"` when omitted. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer. Successful registrations return the exact Cordis disposer for ordered composite teardown.
|
||||
|
||||
### Config
|
||||
|
||||
| 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. 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 an uncooperative provider so agent cancellation cannot hang prefix composition. The provider later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a remote provider can store a URL, id, or version token.
|
||||
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.
|
||||
|
||||
The registry validates candidate names, descriptions, ranks, and provider ownership. 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 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.
|
||||
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.
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
@@ -32,64 +32,65 @@ 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 {
|
||||
cwd?: string | undefined
|
||||
/** Workspace selector for the current lookup. */
|
||||
readonly cwd?: string | undefined
|
||||
/** Abort discovery or loading work for the current caller. */
|
||||
signal?: AbortSignal | undefined
|
||||
readonly signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -98,20 +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 - 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' {
|
||||
@@ -161,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
|
||||
@@ -177,63 +178,82 @@ 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. 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 a disposer that unregisters this provider.
|
||||
* @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 dispose = this.ctx.effect(function* (this: SkillService) {
|
||||
if (provider.name === RUNTIME_PROVIDER) {
|
||||
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
|
||||
}
|
||||
if (this.providers.has(provider.name)) {
|
||||
throw new Error(`a skill provider named "${provider.name}" is already registered`)
|
||||
}
|
||||
this.providers.set(provider.name, { provider, order: this.nextProviderOrder })
|
||||
this.nextProviderOrder += 1
|
||||
this.invalidateCache()
|
||||
const name = provider.name
|
||||
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(provider.name)
|
||||
this.invalidateCache()
|
||||
this.ctx.emit('skill/provider-removed', provider.name)
|
||||
providers.delete(name)
|
||||
invalidateCache()
|
||||
ctx.emit('skill/provider-removed', name)
|
||||
}
|
||||
this.ctx.emit('skill/provider-added', provider)
|
||||
}.bind(this), 'skills.registerProvider()')
|
||||
return () => void dispose()
|
||||
ctx.emit('skill/provider-added', provider)
|
||||
}, 'skills.registerProvider()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 a disposer that removes this runtime contribution and invalidates caches.
|
||||
* @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): () => 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()')
|
||||
return () => void dispose()
|
||||
}, 'skills.register()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* List model-invocable skill summaries for a workspace.
|
||||
* 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.
|
||||
*/
|
||||
@@ -246,20 +266,33 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one full skill definition by name.
|
||||
* 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.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns the full skill, including body content, or `undefined`.
|
||||
*/
|
||||
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
|
||||
if (!isSkillName(name)) return undefined
|
||||
const match = (await this.collect(options)).find(entry => entry.candidate.name === name)
|
||||
const collected = await this.collect(options)
|
||||
throwIfAborted(options.signal)
|
||||
const match = collected.find(entry => entry.candidate.name === name)
|
||||
if (match === undefined) return undefined
|
||||
return await match.provider.get(match.candidate, options)
|
||||
const definition = await waitWithAbort(
|
||||
match.provider.get(match.candidate, options),
|
||||
options.signal,
|
||||
)
|
||||
if (definition === undefined) return undefined
|
||||
validateDefinition(definition)
|
||||
return definition
|
||||
}
|
||||
|
||||
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
|
||||
options.signal?.throwIfAborted()
|
||||
throwIfAborted(options.signal)
|
||||
while (true) {
|
||||
const providerRevision = this.providerRevision
|
||||
const runtimeRevision = this.runtimeRevision
|
||||
@@ -268,7 +301,7 @@ export class SkillService extends Service {
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const result = await this.collectFresh(options)
|
||||
options.signal?.throwIfAborted()
|
||||
throwIfAborted(options.signal)
|
||||
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
|
||||
if (result.cacheable) {
|
||||
this.collectCache.set(key, result.entries)
|
||||
@@ -299,7 +332,7 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
options.signal?.throwIfAborted()
|
||||
throwIfAborted(options.signal)
|
||||
const candidates: IndexedCandidate[] = []
|
||||
let cacheable = true
|
||||
let runtimeOrder = 0
|
||||
@@ -314,15 +347,19 @@ 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 (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) {
|
||||
validateCandidate(candidate, provider.name)
|
||||
candidates.push({ candidate, provider, providerOrder: order, localOrder })
|
||||
@@ -345,14 +382,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 } : {},
|
||||
@@ -361,28 +404,68 @@ function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
|
||||
}
|
||||
|
||||
function validateCandidate(candidate: SkillCandidate, providerName: string): void {
|
||||
if (typeof candidate.name !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`)
|
||||
}
|
||||
if (!SKILL_NAME.test(candidate.name)) {
|
||||
throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)
|
||||
}
|
||||
if (typeof candidate.description !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string description`)
|
||||
}
|
||||
if (candidate.description.length === 0) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
|
||||
}
|
||||
if (!Number.isFinite(candidate.rank)) {
|
||||
if (candidate.disableModelInvocation !== undefined && typeof candidate.disableModelInvocation !== 'boolean') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-boolean disableModelInvocation`)
|
||||
}
|
||||
if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`)
|
||||
}
|
||||
if (typeof candidate.source !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string source`)
|
||||
}
|
||||
if (typeof candidate.rank !== 'number' || !Number.isFinite(candidate.rank)) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)
|
||||
}
|
||||
if (typeof candidate.provider !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`)
|
||||
}
|
||||
if (candidate.provider !== providerName) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)
|
||||
}
|
||||
if (candidate.path !== undefined && typeof candidate.path !== 'string') {
|
||||
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string path`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeSkill(skill: SkillRegistration): SkillDefinition {
|
||||
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`)
|
||||
return {
|
||||
...skill,
|
||||
provider: skill.provider ?? RUNTIME_PROVIDER,
|
||||
source: skill.source,
|
||||
}
|
||||
|
||||
/** 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 content = skill.content
|
||||
const path = skill.path
|
||||
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`)
|
||||
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`)
|
||||
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
|
||||
throw new TypeError(`loaded skill "${name}" disableModelInvocation must be a boolean`)
|
||||
}
|
||||
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`)
|
||||
if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`)
|
||||
if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)
|
||||
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`)
|
||||
}
|
||||
|
||||
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
|
||||
@@ -426,7 +509,7 @@ function collectCacheKey(options: SkillLookupOptions, providerRevision: number,
|
||||
|
||||
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (signal === undefined) return promise
|
||||
signal.throwIfAborted()
|
||||
throwIfAborted(signal)
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
@@ -446,16 +529,31 @@ function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined):
|
||||
reject(toError(error))
|
||||
},
|
||||
)
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
/** Throw a total Error for an already-aborted lookup. */
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted === true) throw toError(signal.reason)
|
||||
}
|
||||
|
||||
/** Normalize an arbitrary abort or provider failure without trusting coercion. */
|
||||
function toError(error: unknown): Error {
|
||||
try {
|
||||
if (error instanceof Error) return error
|
||||
} catch {
|
||||
// A hostile proxy may throw during instanceof; fall through to the total renderer.
|
||||
}
|
||||
return new Error(errorMessage(error))
|
||||
}
|
||||
|
||||
/** Render an arbitrary provider failure without letting coercion escape containment. */
|
||||
function errorMessage(error: unknown): string {
|
||||
return String(error)
|
||||
try {
|
||||
return String(error)
|
||||
} catch {
|
||||
return '[unrenderable thrown value]'
|
||||
}
|
||||
}
|
||||
|
||||
export default SkillService
|
||||
|
||||
@@ -107,6 +107,348 @@ describe('SkillService registry', () => {
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
|
||||
})
|
||||
|
||||
it('validates parsed candidate fields', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const badDescription = { value: 'object-description' }
|
||||
ctx.skills.registerProvider({
|
||||
name: 'bad-candidate',
|
||||
list: () => Promise.resolve([{
|
||||
...memorySkill('bad-candidate', 'placeholder', 1),
|
||||
provider: 'bad-candidate',
|
||||
description: badDescription as unknown as string,
|
||||
disableModelInvocation: 'false' as unknown as boolean,
|
||||
}]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
await expect(ctx.skills.list()).rejects.toThrow('non-string description')
|
||||
|
||||
const badBoolean = new Context()
|
||||
await badBoolean.plugin(SkillService)
|
||||
badBoolean.skills.registerProvider({
|
||||
name: 'bad-boolean',
|
||||
list: () => Promise.resolve([{
|
||||
...memorySkill('bad-boolean', 'Bad boolean', 1),
|
||||
provider: 'bad-boolean',
|
||||
disableModelInvocation: 'false' as unknown as boolean,
|
||||
}]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
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)
|
||||
badList.skills.registerProvider({
|
||||
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')
|
||||
|
||||
const cases: { patch: Partial<SkillCandidate>; expected: string }[] = [
|
||||
{ patch: { name: { value: 'candidate' } as unknown as string }, expected: 'non-string skill name' },
|
||||
{ patch: { whenToUse: 1 as unknown as string }, expected: 'non-string whenToUse' },
|
||||
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'non-string source' },
|
||||
{ patch: { rank: '1' as unknown as number }, expected: 'invalid rank' },
|
||||
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'non-string provider' },
|
||||
{ patch: { path: 1 as unknown as string }, expected: 'non-string path' },
|
||||
]
|
||||
for (const [index, { patch, expected }] of cases.entries()) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const providerName = `candidate-provider-${index}`
|
||||
const candidate = {
|
||||
name: `candidate-${index}`,
|
||||
description: 'Candidate',
|
||||
whenToUse: 'Use this candidate.',
|
||||
disableModelInvocation: false,
|
||||
provider: providerName,
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'candidate',
|
||||
path: '/skills/candidate/SKILL.md',
|
||||
...patch,
|
||||
} as SkillCandidate
|
||||
ctx.skills.registerProvider({
|
||||
name: providerName,
|
||||
list: () => Promise.resolve([candidate]),
|
||||
get: () => Promise.resolve(undefined),
|
||||
})
|
||||
|
||||
await expect(ctx.skills.list()).rejects.toThrow(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('borrows the exact lookup options through discovery and loading', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
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(received) {
|
||||
listedWith = received
|
||||
return [candidate]
|
||||
},
|
||||
async get(received, lookup) {
|
||||
expect(received).toBe(candidate)
|
||||
loadedWith = lookup
|
||||
return { ...received, content: 'Skill A body.' }
|
||||
},
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let getCalls = 0
|
||||
ctx.skills.registerProvider({
|
||||
name: 'cached',
|
||||
async list() {
|
||||
return [{
|
||||
name: 'cached-skill',
|
||||
description: 'Cached skill',
|
||||
provider: 'cached',
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'cached',
|
||||
}]
|
||||
},
|
||||
async get(candidate) {
|
||||
getCalls += 1
|
||||
return { ...candidate, content: 'Cached body.' }
|
||||
},
|
||||
})
|
||||
await ctx.skills.list({ cwd: '/workspace/cache' })
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled after cached discovery')
|
||||
|
||||
const pending = ctx.skills.get('cached-skill', {
|
||||
cwd: '/workspace/cache',
|
||||
signal: controller.signal,
|
||||
})
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(getCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('stops waiting for cached provider loading when a hostile abort reason fires', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let markStarted: (() => void) | undefined
|
||||
let release: (() => void) | undefined
|
||||
let seenSignal: AbortSignal | undefined
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
const held = new Promise<SkillDefinition>((resolve) => {
|
||||
release = () => {
|
||||
resolve({
|
||||
name: 'held-skill',
|
||||
description: 'Held skill',
|
||||
provider: 'held',
|
||||
source: 'test',
|
||||
content: 'Held body.',
|
||||
})
|
||||
}
|
||||
})
|
||||
ctx.skills.registerProvider({
|
||||
name: 'held',
|
||||
async list() {
|
||||
return [{
|
||||
name: 'held-skill',
|
||||
description: 'Held skill',
|
||||
provider: 'held',
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'held',
|
||||
}]
|
||||
},
|
||||
get(_candidate, options) {
|
||||
seenSignal = options.signal
|
||||
markStarted?.()
|
||||
return held
|
||||
},
|
||||
})
|
||||
await ctx.skills.list({ cwd: '/workspace/cache' })
|
||||
const controller = new AbortController()
|
||||
const hostileReason = {
|
||||
[Symbol.toPrimitive]() {
|
||||
throw new Error('abort reason coercion failed')
|
||||
},
|
||||
}
|
||||
const pending = ctx.skills.get('held-skill', {
|
||||
cwd: '/workspace/cache',
|
||||
signal: controller.signal,
|
||||
})
|
||||
const outcome = pending.then(
|
||||
() => 'resolved',
|
||||
(error: unknown) => error instanceof Error && error.message === '[unrenderable thrown value]'
|
||||
? 'aborted'
|
||||
: 'other-error',
|
||||
)
|
||||
await started
|
||||
controller.abort(hostileReason)
|
||||
|
||||
const settled = await Promise.race([
|
||||
outcome,
|
||||
new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 25)),
|
||||
])
|
||||
release?.()
|
||||
await pending.catch(() => undefined)
|
||||
|
||||
expect(seenSignal).toBe(controller.signal)
|
||||
expect(settled).toBe('aborted')
|
||||
})
|
||||
|
||||
it('borrows cached candidates and loaded definitions from the provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const locator = { id: 'provider-owned' }
|
||||
const candidate: SkillCandidate = {
|
||||
name: 'stable-skill',
|
||||
description: 'Stable description',
|
||||
whenToUse: 'When stability matters.',
|
||||
disableModelInvocation: false,
|
||||
provider: 'detached',
|
||||
source: 'test',
|
||||
resourceBase: { kind: 'opaque', description: 'candidate resources' },
|
||||
rank: 1,
|
||||
locator,
|
||||
path: '/skills/stable/SKILL.md',
|
||||
metadata: { owner: 'candidate' },
|
||||
}
|
||||
const definition: SkillDefinition = {
|
||||
name: 'stable-skill',
|
||||
description: 'Stable description',
|
||||
whenToUse: 'When stability matters.',
|
||||
disableModelInvocation: false,
|
||||
provider: 'detached',
|
||||
source: 'test',
|
||||
resourceBase: { kind: 'opaque', description: 'definition resources' },
|
||||
path: '/skills/stable/SKILL.md',
|
||||
metadata: { owner: 'definition' },
|
||||
content: 'Stable body.',
|
||||
}
|
||||
let listCalls = 0
|
||||
let received: SkillCandidate | undefined
|
||||
ctx.skills.registerProvider({
|
||||
name: 'detached',
|
||||
async list() {
|
||||
listCalls += 1
|
||||
return [candidate]
|
||||
},
|
||||
async get(loaded) {
|
||||
received = loaded
|
||||
return definition
|
||||
},
|
||||
})
|
||||
|
||||
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).toBe(candidate)
|
||||
expect(received?.locator).toBe(locator)
|
||||
expect(loaded).toBe(definition)
|
||||
})
|
||||
|
||||
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' }
|
||||
const registration = {
|
||||
name: 'runtime-skill',
|
||||
description: 'Runtime',
|
||||
whenToUse: 'When runtime data is needed.',
|
||||
disableModelInvocation: false,
|
||||
source: 'runtime',
|
||||
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.',
|
||||
})
|
||||
const listed = await ctx.skills.list()
|
||||
const loaded = await ctx.skills.get('runtime-skill')
|
||||
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 () => {
|
||||
const cases: { patch: Partial<SkillDefinition>; expected: string }[] = [
|
||||
{ patch: { name: { value: 'loaded' } as unknown as string }, expected: 'loaded skill name must be a string' },
|
||||
{ patch: { name: 'Bad_Name' }, expected: 'loaded skill has invalid name' },
|
||||
{ patch: { description: { value: 'description' } as unknown as string }, expected: 'description must be a string' },
|
||||
{ patch: { description: '' }, expected: 'requires a description' },
|
||||
{ patch: { disableModelInvocation: 'false' as unknown as boolean }, expected: 'disableModelInvocation must be a boolean' },
|
||||
{ patch: { whenToUse: 1 as unknown as string }, expected: 'whenToUse must be a string' },
|
||||
{ patch: { source: { value: 'source' } as unknown as string }, expected: 'source must be a string' },
|
||||
{ patch: { provider: { value: 'provider' } as unknown as string }, expected: 'provider must be a string' },
|
||||
{ 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 [index, { patch, expected }] of cases.entries()) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const providerName = `definition-provider-${index}`
|
||||
const skillName = `definition-${index}`
|
||||
ctx.skills.registerProvider({
|
||||
name: providerName,
|
||||
list: () => Promise.resolve([{
|
||||
name: skillName,
|
||||
description: 'Candidate',
|
||||
provider: providerName,
|
||||
source: 'test',
|
||||
rank: 1,
|
||||
locator: 'definition',
|
||||
}]),
|
||||
get: () => Promise.resolve({
|
||||
name: skillName,
|
||||
description: 'Definition',
|
||||
whenToUse: 'Use this definition.',
|
||||
disableModelInvocation: false,
|
||||
provider: providerName,
|
||||
source: 'test',
|
||||
content: 'Definition body.',
|
||||
path: '/skills/definition/SKILL.md',
|
||||
...patch,
|
||||
} as SkillDefinition),
|
||||
})
|
||||
|
||||
await expect(ctx.skills.get(skillName)).rejects.toThrow(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('validates provider candidates and invalid registry caps', async () => {
|
||||
const defaultedService = new SkillService(new Context())
|
||||
expect(await defaultedService.list()).toEqual([])
|
||||
@@ -224,6 +566,34 @@ describe('SkillService registry', () => {
|
||||
expect(flakyCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('contains a provider rejection whose string coercion throws', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const hostileFailure = {
|
||||
toString() {
|
||||
throw new Error('provider failure coercion failed')
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider({
|
||||
name: 'hostile-failure',
|
||||
list() {
|
||||
// Deliberately violate the provider contract to prove containment is total.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject(hostileFailure)
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await expect(ctx.skills.list()).resolves.toEqual([])
|
||||
expect(warnings).toEqual([
|
||||
'skill provider "hostile-failure" skipped: [unrenderable thrown value]',
|
||||
])
|
||||
})
|
||||
|
||||
it('abandons an in-flight catalog when provider registrations change', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
@@ -293,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)
|
||||
|
||||
@@ -6,7 +6,7 @@ Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`).
|
||||
|
||||
## Session-prefix catalog
|
||||
|
||||
The plugin contributes one user-role `<system-reminder>` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available.
|
||||
The plugin contributes one user-role `<system-reminder>` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available, and also when that agent's tool view restricts away the shipped `skill` tool or resolves a same-name scoped shadow instead. This exact-definition check keeps prompt guidance, the model-visible schema, and executable dispatch aligned.
|
||||
|
||||
`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix RFC](../../../docs/rfc/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of this message.
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -26,18 +26,16 @@ export const Config: z<Config> = z.object({
|
||||
catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH),
|
||||
})
|
||||
|
||||
/** Register the session-prefix skill catalog and the model-facing skill loader. */
|
||||
/**
|
||||
* Register the model-facing skill loader and its visibility-matched
|
||||
* session-prefix catalog. The catalog is emitted only when the calling agent
|
||||
* resolves this plugin's exact tool registration; a restriction or scoped
|
||||
* same-name shadow therefore removes both the schema and its call guidance.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
|
||||
assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3)
|
||||
|
||||
ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise<Message[]> => {
|
||||
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
|
||||
const rest = await next()
|
||||
if (skills.length === 0) return rest
|
||||
return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest]
|
||||
})
|
||||
|
||||
const skillTool = defineTool({
|
||||
name: 'skill',
|
||||
description: 'Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.',
|
||||
@@ -62,6 +60,23 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
},
|
||||
})
|
||||
ctx.tools.register(skillTool)
|
||||
const registeredSkillTool = ctx.tools.get(skillTool.name)
|
||||
/* v8 ignore next 3 -- register() publishes synchronously or throws; this guards future registry drift. */
|
||||
if (registeredSkillTool === undefined) {
|
||||
throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry')
|
||||
}
|
||||
|
||||
// Register after the tool so reverse-order fiber teardown removes this
|
||||
// guidance listener before its referenced tool. Exact definition identity is
|
||||
// the shared truth for restrictions and scoped shadows: another tool merely
|
||||
// named `skill` must not inherit this plugin's catalog or instructions.
|
||||
ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise<Message[]> => {
|
||||
if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next()
|
||||
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
|
||||
const rest = await next()
|
||||
if (skills.length === 0) return rest
|
||||
return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest]
|
||||
})
|
||||
}
|
||||
|
||||
function renderSkillContent(skill: SkillDefinition): string {
|
||||
|
||||
@@ -4,8 +4,10 @@ import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
@@ -30,18 +32,31 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise<Conte
|
||||
return ctx
|
||||
}
|
||||
|
||||
function agentForCwd(cwd: string): never {
|
||||
return { session: { header: { cwd } } } as never
|
||||
function agentForCwd(cwd: string): Agent {
|
||||
return { session: { header: { cwd } } } as unknown as Agent
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
|
||||
return await composePrefixForAgent(ctx, agentForCwd(cwd), signal)
|
||||
}
|
||||
|
||||
async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', agentForCwd(cwd), empty, signal,
|
||||
return await agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function mintAgentScope(ctx: Context, cwd: string): Promise<{ agent: Agent; scope: Scope }> {
|
||||
const agent = agentForCwd(cwd)
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, {
|
||||
inject: ['tools'],
|
||||
}))
|
||||
return { agent, scope }
|
||||
}
|
||||
|
||||
describe('dsh-tool-skill', () => {
|
||||
it('registers the skill tool schema and removes it on dispose', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -152,6 +167,39 @@ describe('dsh-tool-skill', () => {
|
||||
expect(await composePrefix(ctx, '/workspace')).toEqual([])
|
||||
})
|
||||
|
||||
it('omits catalog guidance when the calling agent restricts away the shipped skill tool', async () => {
|
||||
const home = await tempDir('tool-restricted-catalog')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
|
||||
const { agent, scope } = await mintAgentScope(ctx, '/workspace')
|
||||
scope.ctx.tools.restrict({ deny: ['skill'] })
|
||||
|
||||
expect(ctx.tools.get('skill', agent)).toBeUndefined()
|
||||
expect(await composePrefixForAgent(ctx, agent)).toEqual([])
|
||||
expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('does not attach shipped catalog guidance to a scoped same-name tool shadow', async () => {
|
||||
const home = await tempDir('tool-shadowed-catalog')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({ name: 'listed-skill', description: 'Listed', source: 'runtime', content: 'body' })
|
||||
const { agent, scope } = await mintAgentScope(ctx, '/workspace')
|
||||
scope.ctx.tools.register(defineTool({
|
||||
name: 'skill',
|
||||
description: 'A scoped tool with unrelated semantics.',
|
||||
parameters: {},
|
||||
execute() {
|
||||
return Promise.resolve([{ type: 'text', text: 'shadow' }])
|
||||
},
|
||||
}))
|
||||
|
||||
expect(ctx.tools.get('skill', agent)).not.toBe(ctx.tools.get('skill'))
|
||||
expect(await composePrefixForAgent(ctx, agent)).toEqual([])
|
||||
expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('validates the catalog description cap', async () => {
|
||||
const home = await tempDir('tool-invalid-catalog-cap')
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../core/scope" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../skill" },
|
||||
|
||||
Reference in New Issue
Block a user