index.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. /**
  2. * Agent skill provider registry.
  3. *
  4. * This package is the interface third of the skill capability seam. Concrete
  5. * providers such as `@deepseek-ai/dsh-skill-local` decide where skills come
  6. * from; this service only merges provider catalogs, resolves the winning skill
  7. * for a name, and exposes the winning summaries and definitions to consumers.
  8. *
  9. * @module @deepseek-ai/dsh-skill
  10. */
  11. import { Context, Service } from 'cordis'
  12. import z from 'schemastery'
  13. import type Schema from 'schemastery'
  14. const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
  15. const DEFAULT_COLLECT_CACHE_ENTRIES = 128
  16. const RUNTIME_PROVIDER = 'runtime'
  17. const RUNTIME_RANK = 250
  18. /**
  19. * Return whether a string is a valid kebab-case skill name.
  20. * @param name - candidate skill name to validate.
  21. * @returns whether the name matches the public skill-name grammar.
  22. */
  23. export function isSkillName(name: string): boolean {
  24. return SKILL_NAME.test(name)
  25. }
  26. /** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
  27. export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {})
  28. /** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
  29. export type SkillResourceBase =
  30. | { readonly kind: 'directory'; readonly path: string }
  31. | { readonly kind: 'url'; readonly url: string }
  32. | { readonly kind: 'opaque'; readonly description: string }
  33. /** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
  34. export interface SkillSummary {
  35. /** Kebab-case identifier used with the `skill` tool. */
  36. readonly name: string
  37. /** Short routing description shown to the model. */
  38. readonly description: string
  39. /** Optional extra routing guidance shown to the model. */
  40. readonly whenToUse?: string
  41. /** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
  42. readonly disableModelInvocation?: boolean
  43. /** Discovery source that produced this winning skill. */
  44. readonly source: SkillSource
  45. /** Provider that owns this skill body. */
  46. readonly provider: string
  47. /** Provider-specific base for relative resources. */
  48. readonly resourceBase?: SkillResourceBase
  49. }
  50. /** Provider catalog entry used by the registry to merge and later load skills. */
  51. export interface SkillCandidate extends SkillSummary {
  52. /** Lower ranks win duplicate skill names before provider registration order is considered. */
  53. readonly rank: number
  54. /** Opaque provider-owned handle passed back to `provider.get()`. */
  55. readonly locator: unknown
  56. /** Absolute file path when the provider has one. */
  57. readonly path?: string
  58. /** Parsed optional metadata object from provider-specific skill frontmatter. */
  59. readonly metadata?: Readonly<Record<string, unknown>>
  60. }
  61. /** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */
  62. export interface SkillDefinition extends SkillSummary {
  63. /** Markdown instruction body after any provider-specific metadata removal. */
  64. readonly content: string
  65. /** Absolute file path when the skill came from disk. */
  66. readonly path?: string
  67. /** Parsed optional metadata object from frontmatter. */
  68. readonly metadata?: Readonly<Record<string, unknown>>
  69. }
  70. /** Runtime skill contribution accepted by `ctx.skills.register()`. */
  71. export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { readonly provider?: string }
  72. /** Caller context used for cwd-sensitive and abortable provider work. */
  73. export interface SkillLookupOptions {
  74. /** Workspace selector for the current lookup. */
  75. readonly cwd?: string | undefined
  76. /** Abort discovery or loading work for the current caller. */
  77. readonly signal?: AbortSignal | undefined
  78. }
  79. /** Provider interface for one source of skills, such as local directories or a remote registry. */
  80. export interface SkillProvider {
  81. /** Unique provider name in the `ctx.skills` registry. */
  82. readonly name: string
  83. /**
  84. * List available skill candidates for the current lookup context. Provider
  85. * plugins register synchronously during `apply()`; remote initialization,
  86. * authentication, and discovery are awaited inside this method. Implementations
  87. * should settle promptly when `options.signal` aborts.
  88. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
  89. * @returns provider candidates with precedence ranks and opaque locators.
  90. */
  91. readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[]>
  92. /**
  93. * Load a complete skill body for a previously listed candidate.
  94. * @param candidate - the winning candidate originally returned by this provider.
  95. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
  96. * @returns the full skill body, or `undefined` if it is no longer loadable.
  97. */
  98. readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
  99. }
  100. /** Skill registry configuration. */
  101. export interface Config {
  102. /** Maximum number of completed cwd/provider catalogs kept in memory. */
  103. readonly collectCacheMaxEntries?: number
  104. }
  105. declare module 'cordis' {
  106. interface Context {
  107. skills: SkillService
  108. }
  109. }
  110. interface IndexedCandidate {
  111. candidate: SkillCandidate
  112. provider: SkillProvider
  113. providerOrder: number
  114. localOrder: number
  115. }
  116. interface CollectResult {
  117. entries: IndexedCandidate[]
  118. cacheable: boolean
  119. }
  120. /**
  121. * Registry of skill providers. It merges provider catalogs with stable
  122. * first-wins duplicate handling, exposes sorted model-visible summaries, and
  123. * loads full skill bodies on demand.
  124. */
  125. export class SkillService extends Service {
  126. static Config: Schema<Config> = z.object({
  127. collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
  128. })
  129. private readonly collectCacheMaxEntries: number
  130. private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
  131. private readonly runtime = new Map<string, SkillRegistration>()
  132. private readonly collectCache = new Map<string, IndexedCandidate[]>()
  133. private providerRevision = 0
  134. private nextProviderOrder = 0
  135. private runtimeRevision = 0
  136. constructor(ctx: Context, config: Config = {}) {
  137. super(ctx, 'skills')
  138. this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
  139. assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
  140. }
  141. /**
  142. * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and
  143. * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters
  144. * the provider and invalidates catalog caches.
  145. * @param provider - the provider to register by `provider.name`.
  146. * @returns the exact Cordis effect disposer that unregisters this provider;
  147. * composite effects may yield it directly to preserve teardown ordering.
  148. */
  149. registerProvider(provider: SkillProvider): () => void {
  150. const name = provider.name
  151. if (name === RUNTIME_PROVIDER) {
  152. throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
  153. }
  154. if (this.providers.has(name)) {
  155. throw new Error(`a skill provider named "${name}" is already registered`)
  156. }
  157. const providers = this.providers
  158. const order = this.nextProviderOrder
  159. const invalidateCache = (): void => { this.invalidateCache() }
  160. this.nextProviderOrder += 1
  161. const dispose = this.ctx.effect(function* () {
  162. providers.set(name, { provider, order })
  163. invalidateCache()
  164. yield () => {
  165. providers.delete(name)
  166. invalidateCache()
  167. }
  168. }, 'skills.registerProvider()')
  169. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
  170. return dispose
  171. }
  172. /**
  173. * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which
  174. * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and
  175. * receives a no-op disposer so it cannot remove the winner.
  176. * @param skill - the complete skill definition to expose for discovery.
  177. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
  178. */
  179. register(skill: SkillRegistration): () => void {
  180. validateRuntimeSkill(skill)
  181. const existing = this.runtime.get(skill.name)
  182. if (existing !== undefined) {
  183. this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`)
  184. return () => {}
  185. }
  186. const runtime = this.runtime
  187. const updateRevision = (): void => { this.runtimeRevision += 1 }
  188. const invalidateCache = (): void => { this.invalidateCache() }
  189. const dispose = this.ctx.effect(function* () {
  190. runtime.set(skill.name, skill)
  191. updateRevision()
  192. invalidateCache()
  193. yield () => {
  194. runtime.delete(skill.name)
  195. updateRevision()
  196. invalidateCache()
  197. }
  198. }, 'skills.register()')
  199. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
  200. return dispose
  201. }
  202. /**
  203. * List model-invocable skill summaries for a workspace. Lookup options and
  204. * provider candidates are readonly same-process values borrowed throughout
  205. * discovery.
  206. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
  207. * @returns sorted summaries, excluding skills disabled for model invocation.
  208. */
  209. async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
  210. return (await this.collect(options))
  211. .map(entry => entry.candidate)
  212. .filter(skill => skill.disableModelInvocation !== true)
  213. .map(toSummary)
  214. .sort(compareSkillSummary)
  215. }
  216. /**
  217. * Load and validate the winning candidate, passing its opaque discovery locator back to the
  218. * provider. Cancellation is rechecked after selection, including cache hits, and raced against
  219. * loading so an uncooperative provider cannot hang the caller.
  220. * @param name - kebab-case skill name.
  221. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
  222. * @returns the full skill, including body content, or `undefined`.
  223. */
  224. async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
  225. if (!isSkillName(name)) return undefined
  226. const collected = await this.collect(options)
  227. throwIfAborted(options.signal)
  228. const match = collected.find(entry => entry.candidate.name === name)
  229. if (match === undefined) return undefined
  230. const definition = await waitWithAbort(
  231. match.provider.get(match.candidate, options),
  232. options.signal,
  233. )
  234. if (definition === undefined) return undefined
  235. validateDefinition(definition)
  236. return definition
  237. }
  238. private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
  239. throwIfAborted(options.signal)
  240. while (true) {
  241. const providerRevision = this.providerRevision
  242. const runtimeRevision = this.runtimeRevision
  243. const key = collectCacheKey(options, providerRevision, runtimeRevision)
  244. const cached = this.collectCache.get(key)
  245. if (cached !== undefined) return cached
  246. const result = await this.collectFresh(options)
  247. throwIfAborted(options.signal)
  248. if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
  249. if (result.cacheable) {
  250. this.collectCache.set(key, result.entries)
  251. if (this.collectCache.size > this.collectCacheMaxEntries) {
  252. const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
  253. this.collectCache.delete(oldest.value)
  254. }
  255. }
  256. return result.entries
  257. }
  258. }
  259. private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> {
  260. const collected = await this.listAllCandidates(options)
  261. collected.entries.sort(compareIndexedCandidates)
  262. const seen = new Set<string>()
  263. const result: IndexedCandidate[] = []
  264. for (const entry of collected.entries) {
  265. const skill = entry.candidate
  266. if (seen.has(skill.name)) {
  267. this.ctx.logger.warn(`skill "${skill.name}" from ${skill.source} ignored because a higher-priority skill already exists`)
  268. continue
  269. }
  270. seen.add(skill.name)
  271. result.push(entry)
  272. }
  273. return { entries: result, cacheable: collected.cacheable }
  274. }
  275. private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
  276. throwIfAborted(options.signal)
  277. const candidates: IndexedCandidate[] = []
  278. let cacheable = true
  279. let runtimeOrder = 0
  280. for (const skill of [...this.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
  281. candidates.push({
  282. candidate: runtimeCandidate(skill),
  283. provider: RUNTIME_SKILL_PROVIDER,
  284. providerOrder: -1,
  285. localOrder: runtimeOrder,
  286. })
  287. runtimeOrder += 1
  288. }
  289. for (const { provider, order } of [...this.providers.values()]) {
  290. let localOrder = 0
  291. let output: unknown
  292. try {
  293. output = await waitWithAbort(provider.list(options), options.signal)
  294. } catch (error) {
  295. if (options.signal?.aborted === true) throw toError(options.signal.reason)
  296. cacheable = false
  297. this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
  298. }
  299. if (output === undefined) continue
  300. if (!Array.isArray(output)) {
  301. throw new TypeError(`skill provider "${provider.name}" list() must return an array`)
  302. }
  303. const listed = output as readonly SkillCandidate[]
  304. for (const candidate of listed) {
  305. validateCandidate(candidate, provider.name)
  306. candidates.push({ candidate, provider, providerOrder: order, localOrder })
  307. localOrder += 1
  308. }
  309. }
  310. return { entries: candidates, cacheable }
  311. }
  312. private invalidateCache(): void {
  313. this.providerRevision += 1
  314. this.collectCache.clear()
  315. }
  316. }
  317. const RUNTIME_SKILL_PROVIDER: SkillProvider = {
  318. name: RUNTIME_PROVIDER,
  319. /* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */
  320. list() {
  321. return Promise.resolve([])
  322. },
  323. get(candidate) {
  324. const skill = candidate.locator as SkillRegistration
  325. return Promise.resolve({ ...skill, provider: skill.provider ?? RUNTIME_PROVIDER })
  326. },
  327. }
  328. function runtimeCandidate(skill: SkillRegistration): SkillCandidate {
  329. return {
  330. name: skill.name,
  331. description: skill.description,
  332. ...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},
  333. ...skill.disableModelInvocation !== undefined ? { disableModelInvocation: skill.disableModelInvocation } : {},
  334. source: skill.source,
  335. provider: skill.provider ?? RUNTIME_PROVIDER,
  336. ...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {},
  337. rank: RUNTIME_RANK,
  338. locator: skill,
  339. ...skill.path !== undefined ? { path: skill.path } : {},
  340. ...skill.metadata !== undefined ? { metadata: skill.metadata } : {},
  341. }
  342. }
  343. function validateCandidate(candidate: SkillCandidate, providerName: string): void {
  344. if (typeof candidate.name !== 'string') {
  345. throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`)
  346. }
  347. if (!SKILL_NAME.test(candidate.name)) {
  348. throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)
  349. }
  350. if (typeof candidate.description !== 'string') {
  351. throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string description`)
  352. }
  353. if (candidate.description.length === 0) {
  354. throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
  355. }
  356. if (candidate.disableModelInvocation !== undefined && typeof candidate.disableModelInvocation !== 'boolean') {
  357. throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-boolean disableModelInvocation`)
  358. }
  359. if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') {
  360. throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`)
  361. }
  362. if (typeof candidate.source !== 'string') {
  363. throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string source`)
  364. }
  365. if (typeof candidate.rank !== 'number' || !Number.isFinite(candidate.rank)) {
  366. throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)
  367. }
  368. if (typeof candidate.provider !== 'string') {
  369. throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`)
  370. }
  371. if (candidate.provider !== providerName) {
  372. throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)
  373. }
  374. if (candidate.path !== undefined && typeof candidate.path !== 'string') {
  375. throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string path`)
  376. }
  377. }
  378. function validateRuntimeSkill(skill: SkillRegistration): void {
  379. if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
  380. if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
  381. }
  382. /** Validate a definition loaded from a provider-controlled parser or remote source. */
  383. function validateDefinition(skill: SkillDefinition): void {
  384. const name = skill.name
  385. const description = skill.description
  386. const whenToUse = skill.whenToUse
  387. const disableModelInvocation = skill.disableModelInvocation
  388. const source = skill.source
  389. const provider = skill.provider
  390. const content = skill.content
  391. const path = skill.path
  392. if (typeof name !== 'string') throw new TypeError('loaded skill name must be a string')
  393. if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`)
  394. if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`)
  395. if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`)
  396. if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
  397. throw new TypeError(`loaded skill "${name}" disableModelInvocation must be a boolean`)
  398. }
  399. if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`)
  400. if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`)
  401. if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)
  402. if (typeof content !== 'string') throw new TypeError(`loaded skill "${name}" content must be a string`)
  403. if (path !== undefined && typeof path !== 'string') throw new TypeError(`loaded skill "${name}" path must be a string`)
  404. }
  405. function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
  406. const { name, description, whenToUse, disableModelInvocation, source, provider, resourceBase } = skill
  407. return {
  408. name,
  409. description,
  410. ...whenToUse !== undefined ? { whenToUse } : {},
  411. ...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
  412. source,
  413. provider,
  414. ...resourceBase !== undefined ? { resourceBase } : {},
  415. }
  416. }
  417. function compareSkillSummary(left: SkillSummary, right: SkillSummary): number {
  418. return compareCodePoints(left.name, right.name)
  419. }
  420. function compareCodePoints(left: string, right: string): number {
  421. if (left < right) return -1
  422. if (left > right) return 1
  423. return 0
  424. }
  425. function compareIndexedCandidates(left: IndexedCandidate, right: IndexedCandidate): number {
  426. return left.candidate.rank - right.candidate.rank
  427. || left.providerOrder - right.providerOrder
  428. || left.localOrder - right.localOrder
  429. }
  430. function assertPositiveInteger(name: string, value: number, minimum = 1): void {
  431. if (!Number.isInteger(value) || value < minimum) {
  432. throw new Error(`skill: ${name} must be an integer greater than or equal to ${minimum}`)
  433. }
  434. }
  435. function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string {
  436. return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
  437. }
  438. function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
  439. if (signal === undefined) return promise
  440. throwIfAborted(signal)
  441. return new Promise<T>((resolve, reject) => {
  442. const cleanup = (): void => {
  443. signal.removeEventListener('abort', onAbort)
  444. }
  445. const onAbort = (): void => {
  446. cleanup()
  447. reject(toError(signal.reason))
  448. }
  449. signal.addEventListener('abort', onAbort, { once: true })
  450. void promise.then(
  451. (value) => {
  452. cleanup()
  453. resolve(value)
  454. },
  455. (error: unknown) => {
  456. cleanup()
  457. reject(toError(error))
  458. },
  459. )
  460. })
  461. }
  462. /** Throw a total Error for an already-aborted lookup. */
  463. function throwIfAborted(signal: AbortSignal | undefined): void {
  464. if (signal?.aborted === true) throw toError(signal.reason)
  465. }
  466. /** Normalize an arbitrary abort or provider failure without trusting coercion. */
  467. function toError(error: unknown): Error {
  468. try {
  469. if (error instanceof Error) return error
  470. } catch {
  471. // A hostile proxy may throw during instanceof; fall through to the total renderer.
  472. }
  473. return new Error(errorMessage(error))
  474. }
  475. /** Render an arbitrary provider failure without letting coercion escape containment. */
  476. function errorMessage(error: unknown): string {
  477. try {
  478. return String(error)
  479. } catch {
  480. return '[unrenderable thrown value]'
  481. }
  482. }
  483. export default SkillService