feat(skill): move catalogs into session prefixes
This commit is contained in:
11
packages/skill/README.md
Normal file
11
packages/skill/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# skill/ - skill capability family
|
||||
|
||||
The canonical three-package capability seam for reusable agent instructions: a provider registry, a local implementation, and the model-facing catalog/loader consumer. All are **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `skill/` | Provider registry, precedence resolution, stable catalog snapshots, and full-definition lookup | `ctx.skills` |
|
||||
| `skill-local/` | Project/custom/user filesystem provider | (registers on `ctx.skills`) |
|
||||
| `tool-skill/` | Session-prefix catalog and model-facing `skill` loader | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `skill/skill/`. Providers register synchronously and perform asynchronous discovery through `ctx.skills`; `tool-skill` consumes only that interface, so an embedded or remote provider can replace or complement `skill-local` without changing the model-facing contract. `agent-core` loads this family by default, but it remains a capability outside the core control spine, parallel to [`bash/`](../bash/README.md), [`fs/`](../fs/README.md), [`web/`](../web/README.md), and [`subagent/`](../subagent/README.md).
|
||||
37
packages/skill/skill-local/README.md
Normal file
37
packages/skill/skill-local/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @deepseek-ai/dsh-skill-local
|
||||
|
||||
Local filesystem provider for the `ctx.skills` registry.
|
||||
|
||||
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the session-prefix catalog and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
|
||||
|
||||
## Plugin
|
||||
|
||||
Requires `ctx.skills` (`inject: ['skills']`).
|
||||
|
||||
### Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; scans `skills` under this directory. |
|
||||
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
|
||||
| `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. |
|
||||
|
||||
## Discovery
|
||||
|
||||
Default roots are resolved in this provider's rank order:
|
||||
|
||||
| Rank | Source | Path |
|
||||
|---|---|---|
|
||||
| 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
|
||||
| 200 | `project-agents` | `<projectRoot>/.agents/skills` |
|
||||
| 300 | `custom` | `Config.customSkillDirs` |
|
||||
| 400 | `user-dsh` | `<dshHome>/skills` |
|
||||
| 500 | `user-agents` | `<agentsHome>/skills` |
|
||||
|
||||
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not accidentally treated as normal user skills. DeepSeek Harness no longer ships built-in system skills from this provider; additional built-ins can be supplied later by another provider.
|
||||
|
||||
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Without a filesystem service, the provider falls back to Node filesystem I/O so minimal local contexts can still load skills. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request.
|
||||
|
||||
## Skill Format
|
||||
|
||||
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case.
|
||||
38
packages/skill/skill-local/package.json
Normal file
38
packages/skill/skill-local/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-skill-local",
|
||||
"description": "Local filesystem skill provider for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
413
packages/skill/skill-local/src/index.ts
Normal file
413
packages/skill/skill-local/src/index.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Local filesystem skill provider.
|
||||
*
|
||||
* This package is one implementation of the `ctx.skills` provider registry. It
|
||||
* discovers directory-bundle and flat Markdown skills from project, custom, and
|
||||
* user roots, parses YAML frontmatter, and loads bodies through `ctx.fs` when a
|
||||
* filesystem service is present.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-skill-local
|
||||
*/
|
||||
|
||||
import { access, readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type Schema from 'schemastery'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import {
|
||||
isSkillName,
|
||||
type SkillCandidate,
|
||||
type SkillDefinition,
|
||||
type SkillLookupOptions,
|
||||
type SkillProvider,
|
||||
type SkillSource,
|
||||
} from '@deepseek-ai/dsh-skill'
|
||||
|
||||
const PROJECT_DSH_RANK = 100
|
||||
const PROJECT_AGENTS_RANK = 200
|
||||
const CUSTOM_RANK = 300
|
||||
const USER_DSH_RANK = 400
|
||||
const USER_AGENTS_RANK = 500
|
||||
|
||||
export const name = 'skill-local'
|
||||
export const inject = ['skills']
|
||||
|
||||
/** Local filesystem skill provider configuration. */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
|
||||
agentsHome?: string
|
||||
/** Additional skill roots scanned after project roots and before user roots. */
|
||||
customSkillDirs?: string[]
|
||||
}
|
||||
|
||||
export const Config: Schema<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
agentsHome: z.string(),
|
||||
customSkillDirs: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
interface SkillRoot {
|
||||
path: string
|
||||
source: SkillSource
|
||||
rank: number
|
||||
skipSystem?: boolean
|
||||
}
|
||||
|
||||
interface SkillRootEntry {
|
||||
name: string
|
||||
type: 'directory' | 'file' | 'other'
|
||||
path: string
|
||||
}
|
||||
|
||||
interface ParsedSkill {
|
||||
name: string
|
||||
description: string
|
||||
whenToUse?: string
|
||||
disableModelInvocation?: boolean
|
||||
metadata?: Record<string, unknown>
|
||||
content: string
|
||||
}
|
||||
|
||||
interface LocalLocator {
|
||||
path: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
/** Register the local filesystem skill provider on `ctx.skills`. */
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const provider = new LocalSkillProvider(ctx, config)
|
||||
ctx.skills.registerProvider(provider)
|
||||
}
|
||||
|
||||
/** Provider that maps local project/user skill roots into `ctx.skills`. */
|
||||
export class LocalSkillProvider implements SkillProvider {
|
||||
readonly name = 'local'
|
||||
private readonly dshHome: string
|
||||
private readonly agentsHome: string
|
||||
private readonly customSkillDirs: string[]
|
||||
|
||||
constructor(private readonly ctx: Context, config: Config = {}) {
|
||||
this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
|
||||
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
|
||||
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover local skill summaries for a cwd-sensitive workspace.
|
||||
* @param options - lookup options; `cwd` selects the project roots to scan.
|
||||
* @returns local provider candidates with stable root ranks.
|
||||
*/
|
||||
async list(options: SkillLookupOptions): Promise<SkillCandidate[]> {
|
||||
const roots = await this.roots(options.cwd)
|
||||
const candidates: SkillCandidate[] = []
|
||||
for (const root of roots) {
|
||||
for (const skill of await discoverRoot(root, this.ctx)) {
|
||||
candidates.push(skill)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a complete local skill body from the candidate's file locator.
|
||||
* @param candidate - the winning candidate returned by this provider.
|
||||
* @returns the full local skill, or `undefined` if the file disappeared.
|
||||
*/
|
||||
async get(candidate: SkillCandidate): Promise<SkillDefinition | undefined> {
|
||||
const locator = candidate.locator as LocalLocator
|
||||
const parsed = await parseSkillFile(locator.path, this.ctx)
|
||||
if (parsed === undefined) return undefined
|
||||
return {
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
|
||||
source: candidate.source,
|
||||
provider: this.name,
|
||||
resourceBase: { kind: 'directory', path: locator.directory },
|
||||
path: locator.path,
|
||||
...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
|
||||
content: parsed.content,
|
||||
}
|
||||
}
|
||||
|
||||
private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
|
||||
const roots: SkillRoot[] = []
|
||||
if (cwd !== undefined) {
|
||||
const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
|
||||
roots.push(
|
||||
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK },
|
||||
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK },
|
||||
)
|
||||
}
|
||||
roots.push(
|
||||
...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })),
|
||||
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
|
||||
{ path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
|
||||
)
|
||||
return roots
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandidate[]> {
|
||||
const skills: SkillCandidate[] = []
|
||||
const entries = await listSkillRootEntries(root, ctx)
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (root.skipSystem && entry.name === '.system') continue
|
||||
const locator = entry.type === 'directory'
|
||||
? { path: join(entry.path, 'SKILL.md'), directory: entry.path }
|
||||
: entry.type === 'file' && entry.name.endsWith('.md')
|
||||
? { path: entry.path, directory: root.path }
|
||||
: undefined
|
||||
if (locator === undefined) continue
|
||||
const parsed = await parseSkillFile(locator.path, ctx)
|
||||
if (parsed === undefined) continue
|
||||
skills.push({
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
|
||||
provider: 'local',
|
||||
source: root.source,
|
||||
rank: root.rank,
|
||||
locator,
|
||||
resourceBase: { kind: 'directory', path: locator.directory },
|
||||
path: locator.path,
|
||||
...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
|
||||
})
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs)
|
||||
return await listSkillRootEntriesFromNode(root, ctx)
|
||||
}
|
||||
|
||||
async function listSkillRootEntriesFromFileSystem(root: SkillRoot, fs: FileSystem): Promise<SkillRootEntry[]> {
|
||||
// Skill roots are optional; an absent or unlistable root contributes no skills.
|
||||
const entries = await fsListDir(fs, root.path).catch(() => undefined)
|
||||
return entries === undefined ? [] : entries.map(entryFromFs)
|
||||
}
|
||||
|
||||
async function fsListDir(fs: FileSystem, path: string): Promise<FsDirEntry[]> {
|
||||
const target = await fs.resolve(path)
|
||||
return await fs.listDir(target)
|
||||
}
|
||||
|
||||
function entryFromFs(entry: FsDirEntry): SkillRootEntry {
|
||||
return { name: entry.name, type: entry.type, path: entry.target.displayPath }
|
||||
}
|
||||
|
||||
async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
|
||||
} catch {
|
||||
// Missing or unreadable local skill roots are expected in most deployments.
|
||||
return []
|
||||
}
|
||||
|
||||
const result: SkillRootEntry[] = []
|
||||
for (const entry of entries) {
|
||||
const path = join(root.path, entry.name)
|
||||
const type = await nodeEntryKind(path, entry, ctx)
|
||||
result.push({ name: entry.name, type: type ?? 'other', path })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function parseSkillFile(path: string, ctx: Context): Promise<ParsedSkill | undefined> {
|
||||
const raw = await readSkillText(ctx, path)
|
||||
if (raw === undefined) {
|
||||
return undefined
|
||||
}
|
||||
let parsed
|
||||
try {
|
||||
parsed = parseFrontmatter(raw)
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`)
|
||||
return undefined
|
||||
}
|
||||
if (!parsed) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
|
||||
return undefined
|
||||
}
|
||||
const name = stringField(parsed.data, 'name')
|
||||
const description = stringField(parsed.data, 'description')
|
||||
if (name === undefined || description === undefined) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: frontmatter requires name and description`)
|
||||
return undefined
|
||||
}
|
||||
if (!isSkillName(name)) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...optionalString(parsed.data, 'whenToUse'),
|
||||
...optionalBoolean(parsed.data, 'disableModelInvocation'),
|
||||
...optionalMetadata(parsed.data),
|
||||
content: parsed.body.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function optionalFileSystem(ctx: Context): FileSystem | undefined {
|
||||
return ctx.get('fs')
|
||||
}
|
||||
|
||||
async function readSkillText(ctx: Context, path: string): Promise<string | undefined> {
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) {
|
||||
return await readSkillTextFromFileSystem(ctx, fs, path)
|
||||
}
|
||||
try {
|
||||
return await readFile(path, 'utf8')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string): Promise<string | undefined> {
|
||||
// A missing or temporarily inaccessible skill file is not fatal to discovery.
|
||||
const target = await fs.resolve(path).catch(() => undefined)
|
||||
if (target === undefined) return undefined
|
||||
const info = await fs.stat(target).catch((error: unknown) => {
|
||||
ctx.logger.warn(`skill file ${path} ignored: failed to stat through filesystem service: ${errorMessage(error)}`)
|
||||
return undefined
|
||||
})
|
||||
if (info === undefined || info.type !== 'file') return undefined
|
||||
try {
|
||||
return await fs.readText(target)
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function fsReadErrorMessage(target: FsTarget, error: unknown): string {
|
||||
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
|
||||
}
|
||||
|
||||
async function nodeEntryKind(fullPath: string, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }, ctx: Context): Promise<'directory' | 'file' | undefined> {
|
||||
if (entry.isDirectory()) return 'directory'
|
||||
if (entry.isFile()) return 'file'
|
||||
/* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */
|
||||
if (!entry.isSymbolicLink()) return undefined
|
||||
try {
|
||||
const info = await stat(fullPath)
|
||||
if (info.isDirectory()) return 'directory'
|
||||
if (info.isFile()) return 'file'
|
||||
return undefined
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
|
||||
const firstLineEnd = raw.indexOf('\n')
|
||||
if (firstLineEnd < 0) return undefined
|
||||
const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
|
||||
if (firstLine !== '---') return undefined
|
||||
const start = firstLineEnd + 1
|
||||
const closing = findClosingFrontmatter(raw, start)
|
||||
if (closing === undefined) return undefined
|
||||
const yaml = raw.slice(start, closing.start)
|
||||
const parsed = parseYaml(yaml) as unknown
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
|
||||
return { data: parsed as Record<string, unknown>, body: raw.slice(closing.bodyStart) }
|
||||
}
|
||||
|
||||
function findClosingFrontmatter(raw: string, start: number): { start: number; bodyStart: number } | undefined {
|
||||
let lineStart = start
|
||||
while (lineStart <= raw.length) {
|
||||
const nextNewline = raw.indexOf('\n', lineStart)
|
||||
const lineEnd = nextNewline < 0 ? raw.length : nextNewline
|
||||
const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '')
|
||||
if (line === '---') {
|
||||
return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }
|
||||
}
|
||||
if (nextNewline < 0) return undefined
|
||||
lineStart = nextNewline + 1
|
||||
}
|
||||
}
|
||||
|
||||
async function findProjectRoot(cwd: string, fs: FileSystem | undefined): Promise<string> {
|
||||
let current = cwd
|
||||
while (true) {
|
||||
if (await pathExists(join(current, '.git'), fs)) {
|
||||
return current
|
||||
}
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return cwd
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(path: string, fs: FileSystem | undefined): Promise<boolean> {
|
||||
if (fs !== undefined) {
|
||||
return await pathExistsInFileSystem(path, fs)
|
||||
}
|
||||
return await pathExistsInNode(path)
|
||||
}
|
||||
|
||||
async function pathExistsInFileSystem(path: string, fs: FileSystem): Promise<boolean> {
|
||||
let target
|
||||
try {
|
||||
target = await fs.resolve(path)
|
||||
} catch {
|
||||
// A backend may reject or hide this candidate; continue walking upward.
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return await fs.stat(target) !== undefined
|
||||
} catch {
|
||||
// Transient stat failures make only this git-root candidate unusable.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExistsInNode(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch {
|
||||
// Missing host paths are expected while walking toward the filesystem root.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stringField(data: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = data[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function optionalString(data: Record<string, unknown>, key: string): { [K in typeof key]?: string } {
|
||||
const value = data[key]
|
||||
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
|
||||
}
|
||||
|
||||
function optionalBoolean(data: Record<string, unknown>, key: string): { [K in typeof key]?: boolean } {
|
||||
const value = data[key]
|
||||
return typeof value === 'boolean' ? { [key]: value } : {}
|
||||
}
|
||||
|
||||
function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {
|
||||
const value = data.metadata
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return { metadata: value as Record<string, unknown> }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return String(error)
|
||||
}
|
||||
352
packages/skill/skill-local/tests/skill-local.spec.ts
Normal file
352
packages/skill/skill-local/tests/skill-local.spec.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdir, readdir, readFile, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import { FileSystem, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import * as SkillLocal from '../src/index.ts'
|
||||
|
||||
async function tempDir(name: string): Promise<string> {
|
||||
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise<void> {
|
||||
const dir = join(root, name)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
|
||||
}
|
||||
|
||||
async function writeFlatSkill(root: string, name: string, description: string, body = 'Flat body.'): Promise<void> {
|
||||
await mkdir(root, { recursive: true })
|
||||
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
|
||||
}
|
||||
|
||||
class TestFileSystem extends FileSystem {
|
||||
listDirCalls = 0
|
||||
failResolvePaths = new Set<string>()
|
||||
failStatPaths = new Set<string>()
|
||||
statOverrides = new Map<string, FsInfo | undefined>()
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
if (this.failResolvePaths.has(path)) throw new Error('resolve failed')
|
||||
return { targetKey: path as never, displayPath: path }
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
if (this.failStatPaths.has(target.displayPath)) throw new Error('stat failed')
|
||||
if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath)
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
const info = await fs.stat(target.displayPath)
|
||||
return {
|
||||
version: FsVersion(String(info.mtimeMs)),
|
||||
type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
|
||||
size: info.size,
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
const text = await readFile(target.displayPath, 'utf8')
|
||||
if (text.includes('\uFFFD')) throw new Error('not text')
|
||||
return text
|
||||
}
|
||||
|
||||
override async streamText(_target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
throw new Error('not needed in skill tests')
|
||||
}
|
||||
|
||||
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
|
||||
this.listDirCalls += 1
|
||||
const entries = await readdir(target.displayPath, { withFileTypes: true, encoding: 'utf8' })
|
||||
const result: FsDirEntry[] = []
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const childPath = join(target.displayPath, entry.name)
|
||||
let type: FsInfo['type'] = 'other'
|
||||
let size: number | undefined
|
||||
try {
|
||||
const info = await stat(childPath)
|
||||
type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
|
||||
size = info.isFile() ? info.size : undefined
|
||||
} catch {
|
||||
type = 'other'
|
||||
}
|
||||
result.push({
|
||||
name: entry.name,
|
||||
type,
|
||||
target: { targetKey: childPath as never, displayPath: childPath },
|
||||
version: FsVersion('test'),
|
||||
...(size !== undefined ? { size } : {}),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
|
||||
await mkdir(dirname(target.displayPath), { recursive: true })
|
||||
await writeFile(target.displayPath, content)
|
||||
return { operation: 'create', version: FsVersion('test'), before: null, after: content }
|
||||
}
|
||||
|
||||
override async editText(_target: FsTarget, _request: FsEditRequest): Promise<FsEditOutcome> {
|
||||
throw new Error('not needed in skill tests')
|
||||
}
|
||||
}
|
||||
|
||||
async function setupLocal(home: string, config: Partial<SkillLocal.Config> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
...config,
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('dsh-skill-local plugin exports', () => {
|
||||
it('declares stable plugin metadata', () => {
|
||||
expect(SkillLocal.name).toBe('skill-local')
|
||||
expect(SkillLocal.inject).toEqual(['skills'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalSkillProvider', () => {
|
||||
it('discovers project, custom, user, and agents skill roots in priority order', async () => {
|
||||
const home = await tempDir('skill-home')
|
||||
const project = await tempDir('skill-project')
|
||||
const custom = await tempDir('skill-custom')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
|
||||
await writeSkill(join(home, '.agents/skills'), 'same', 'user agents skill')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill')
|
||||
await writeSkill(custom, 'same', 'custom skill')
|
||||
await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill')
|
||||
await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill')
|
||||
await writeSkill(custom, 'custom-only', 'custom only')
|
||||
await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system')
|
||||
|
||||
const ctx = await setupLocal(home, { customSkillDirs: [custom] })
|
||||
|
||||
const skills = await ctx.skills.list({ cwd: join(project, 'src') })
|
||||
expect(skills.map(skill => [skill.name, skill.description])).toEqual([
|
||||
['custom-only', 'custom only'],
|
||||
['same', 'project dsh skill'],
|
||||
])
|
||||
expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
|
||||
expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
|
||||
|
||||
const noGit = await tempDir('skill-no-git')
|
||||
await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root')
|
||||
expect((await ctx.skills.list({ cwd: noGit })).map(skill => skill.name)).toContain('fallback-root')
|
||||
})
|
||||
|
||||
it('lets project skills override runtime while runtime overrides custom and user skills', async () => {
|
||||
const home = await tempDir('skill-runtime-priority')
|
||||
const project = await tempDir('skill-runtime-project')
|
||||
const custom = await tempDir('skill-runtime-custom')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
|
||||
await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins')
|
||||
await writeSkill(custom, 'runtime-name', 'Custom loses')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses')
|
||||
|
||||
const ctx = await setupLocal(home, { customSkillDirs: [custom] })
|
||||
ctx.skills.register({
|
||||
name: 'project-name',
|
||||
description: 'Runtime loses to project',
|
||||
content: 'Runtime body.',
|
||||
source: 'runtime',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'runtime-name',
|
||||
description: 'Runtime wins',
|
||||
content: 'Runtime body.',
|
||||
source: 'runtime',
|
||||
})
|
||||
|
||||
expect((await ctx.skills.get('project-name', { cwd: project }))?.description).toBe('Project wins')
|
||||
expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
|
||||
})
|
||||
|
||||
it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
|
||||
const home = await tempDir('skill-flat')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.')
|
||||
await writeFile(join(root, 'rich-skill.md'), [
|
||||
'---',
|
||||
'name: rich-skill',
|
||||
'description: rich description',
|
||||
'whenToUse: For richer local parsing',
|
||||
'disableModelInvocation: false',
|
||||
'metadata:',
|
||||
' owner: tests',
|
||||
'---',
|
||||
'',
|
||||
'Rich body.',
|
||||
].join('\n'))
|
||||
await writeFile(join(root, 'bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
|
||||
await writeFile(join(root, 'missing-description.md'), '---\nname: missing-description\n---\n\nbad')
|
||||
await writeFile(join(root, 'no-frontmatter.md'), 'No frontmatter.')
|
||||
await writeFile(join(root, 'plain-markdown.md'), '# Notes\nNot a skill.')
|
||||
await writeFile(join(root, 'open-frontmatter.md'), '---\nname: open-frontmatter')
|
||||
await writeFile(join(root, 'non-object.md'), '---\n[]\n---\n\nbad')
|
||||
await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
|
||||
await writeFile(join(root, 'notes.txt'), 'ignored')
|
||||
await mkdir(join(root, 'not-a-skill'), { recursive: true })
|
||||
await writeSkill(root, 'hidden-skill', 'hidden description', 'Hidden.')
|
||||
await writeFile(join(root, 'hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
const listedBeforeDelete = await ctx.skills.list()
|
||||
const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill')
|
||||
if (flatSummary === undefined) throw new Error('expected flat-skill')
|
||||
await writeFile(join(root, 'flat-skill.md'), '')
|
||||
|
||||
expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill'])
|
||||
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
|
||||
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
|
||||
expect(await ctx.skills.get('rich-skill')).toMatchObject({
|
||||
whenToUse: 'For richer local parsing',
|
||||
disableModelInvocation: false,
|
||||
metadata: { owner: 'tests' },
|
||||
})
|
||||
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
|
||||
const home = await tempDir('skill-frontmatter-crlf')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await mkdir(root, { recursive: true })
|
||||
await writeFile(join(root, 'crlf-skill.md'), [
|
||||
'---',
|
||||
'name: crlf-skill',
|
||||
'description: CRLF skill',
|
||||
'metadata:',
|
||||
' marker: "----"',
|
||||
'---',
|
||||
'',
|
||||
'CRLF body.',
|
||||
].join('\r\n'))
|
||||
await writeFile(join(root, 'block-skill.md'), [
|
||||
'---',
|
||||
'name: block-skill',
|
||||
'description: |',
|
||||
' Includes a ---- marker that is not a delimiter.',
|
||||
'---',
|
||||
'',
|
||||
'Block body.',
|
||||
].join('\n'))
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
|
||||
expect((await ctx.skills.get('crlf-skill'))?.content).toBe('CRLF body.')
|
||||
expect((await ctx.skills.get('crlf-skill'))?.metadata).toEqual({ marker: '----' })
|
||||
expect((await ctx.skills.get('block-skill'))?.description).toBe('Includes a ---- marker that is not a delimiter.\n')
|
||||
expect((await ctx.skills.get('block-skill'))?.content).toBe('Block body.')
|
||||
})
|
||||
|
||||
it('skips invalid YAML skill files without hiding valid siblings', async () => {
|
||||
const home = await tempDir('skill-invalid-yaml')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeSkill(root, 'good-skill', 'Good skill')
|
||||
await writeFile(join(root, 'bad-yaml.md'), '---\nname: bad-yaml\ndescription: [unclosed\n---\n\nBad body.\n')
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
|
||||
})
|
||||
|
||||
it('discovers symlinked skill directories and flat files', async () => {
|
||||
const home = await tempDir('skill-symlink-home')
|
||||
const external = await tempDir('skill-symlink-external')
|
||||
await writeSkill(external, 'linked-dir', 'Linked directory')
|
||||
await writeFlatSkill(external, 'linked-flat', 'Linked flat')
|
||||
await mkdir(join(home, '.dsh/skills'), { recursive: true })
|
||||
await symlink(join(external, 'linked-dir'), join(home, '.dsh/skills/linked-dir'))
|
||||
await symlink(join(external, 'linked-flat.md'), join(home, '.dsh/skills/linked-flat.md'))
|
||||
await symlink(join(external, 'missing'), join(home, '.dsh/skills/broken-link'))
|
||||
await symlink('/dev/null', join(home, '.dsh/skills/device-link'))
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
|
||||
})
|
||||
|
||||
it('uses the filesystem service for discovery, reads, and project-root lookup', async () => {
|
||||
const home = await tempDir('skill-read-fs')
|
||||
const project = await tempDir('skill-project-root-backend')
|
||||
const nestedCwd = join(project, 'packages/app')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await mkdir(nestedCwd, { recursive: true })
|
||||
await writeFlatSkill(root, 'text-skill', 'Text skill', 'Text body.')
|
||||
await writeFlatSkill(root, 'resolve-fail', 'Resolve fail', 'Resolve body.')
|
||||
await writeFlatSkill(root, 'stat-fail', 'Stat fail', 'Stat body.')
|
||||
await mkdir(join(root, 'empty-dir'), { recursive: true })
|
||||
await mkdir(join(root, 'directory-skill/SKILL.md'), { recursive: true })
|
||||
await writeFile(join(root, 'binary-skill.md'), Buffer.concat([
|
||||
Buffer.from('---\nname: binary-skill\ndescription: Binary skill\n---\n\n'),
|
||||
Buffer.from([0xff]),
|
||||
Buffer.from('\n'),
|
||||
]))
|
||||
await writeSkill(join(project, '.agents/skills'), 'backend-root', 'Backend root skill')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TestFileSystem)
|
||||
const fs = ctx.fs as TestFileSystem
|
||||
fs.failResolvePaths.add(join(root, 'resolve-fail.md'))
|
||||
fs.failStatPaths.add(join(root, 'stat-fail.md'))
|
||||
fs.failResolvePaths.add(join(nestedCwd, '.git'))
|
||||
fs.failStatPaths.add(join(project, 'packages/.git'))
|
||||
fs.statOverrides.set(join(project, '.git'), {
|
||||
version: FsVersion('virtual-git'),
|
||||
type: 'directory',
|
||||
size: 0,
|
||||
})
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
|
||||
expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
|
||||
['backend-root', 'project-agents'],
|
||||
['text-skill', 'user-dsh'],
|
||||
])
|
||||
expect(fs.listDirCalls).toBeGreaterThan(0)
|
||||
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses default home root resolution without exposing builtin skills', async () => {
|
||||
const previousDshHome = process.env.DSH_HOME
|
||||
const previousAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const envHome = await tempDir('skill-env-home')
|
||||
try {
|
||||
process.env.DSH_HOME = join(envHome, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(envHome, '.agents')
|
||||
await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill'])
|
||||
|
||||
process.env.DSH_HOME = join(envHome, 'empty-dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
|
||||
const empty = new Context()
|
||||
await empty.plugin(SkillService)
|
||||
SkillLocal.apply(empty, {})
|
||||
expect(await empty.skills.list()).toEqual([])
|
||||
} finally {
|
||||
if (previousDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = previousDshHome
|
||||
}
|
||||
if (previousAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = previousAgentsHome
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
15
packages/skill/skill-local/tsconfig.json
Normal file
15
packages/skill/skill-local/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../fs/fs" },
|
||||
{ "path": "../skill" }
|
||||
]
|
||||
}
|
||||
34
packages/skill/skill/README.md
Normal file
34
packages/skill/skill/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-skill
|
||||
|
||||
Pure agent skill provider registry.
|
||||
|
||||
This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local).
|
||||
|
||||
## Service: `SkillService` (ctx key: `skills`)
|
||||
|
||||
### 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.
|
||||
|
||||
### Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalog snapshots 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Consumer boundary
|
||||
|
||||
The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide the session-prefix catalog and `skill` tool, so providers remain independent of the model surface.
|
||||
33
packages/skill/skill/package.json
Normal file
33
packages/skill/skill/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-skill",
|
||||
"description": "Agent skill provider registry for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
461
packages/skill/skill/src/index.ts
Normal file
461
packages/skill/skill/src/index.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Agent skill provider registry.
|
||||
*
|
||||
* This package is the interface third of the skill capability seam. Concrete
|
||||
* providers such as `@deepseek-ai/dsh-skill-local` decide where skills come
|
||||
* from; this service only merges provider catalogs, resolves the winning skill
|
||||
* for a name, and exposes the winning summaries and definitions to consumers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-skill
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type Schema from 'schemastery'
|
||||
|
||||
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
|
||||
const RUNTIME_PROVIDER = 'runtime'
|
||||
const RUNTIME_RANK = 250
|
||||
|
||||
/**
|
||||
* Return whether a string is a valid kebab-case skill name.
|
||||
* @param name - candidate skill name to validate.
|
||||
* @returns whether the name matches the public skill-name grammar.
|
||||
*/
|
||||
export function isSkillName(name: string): boolean {
|
||||
return SKILL_NAME.test(name)
|
||||
}
|
||||
|
||||
/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
|
||||
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {})
|
||||
|
||||
/** 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 }
|
||||
|
||||
/** 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
|
||||
/** Short routing description shown to the model. */
|
||||
description: string
|
||||
/** Optional extra routing guidance shown to the model. */
|
||||
whenToUse?: string
|
||||
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
|
||||
disableModelInvocation?: boolean
|
||||
/** Discovery source that produced this winning skill. */
|
||||
source: SkillSource
|
||||
/** Provider that owns this skill body. */
|
||||
provider: string
|
||||
/** Provider-specific base for relative resources. */
|
||||
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
|
||||
/** Opaque provider-owned handle passed back to `provider.get()`. */
|
||||
locator: unknown
|
||||
/** Absolute file path when the provider has one. */
|
||||
path?: string
|
||||
/** Parsed optional metadata object from provider-specific skill frontmatter. */
|
||||
metadata?: 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
|
||||
/** Absolute file path when the skill came from disk. */
|
||||
path?: string
|
||||
/** Parsed optional metadata object from frontmatter. */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
|
||||
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { provider?: string }
|
||||
|
||||
/** Caller context used for cwd-sensitive and abortable provider work. */
|
||||
export interface SkillLookupOptions {
|
||||
cwd?: string | undefined
|
||||
/** Abort discovery or loading work for the current caller. */
|
||||
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
|
||||
/**
|
||||
* List available skill candidates for the current lookup context. Provider
|
||||
* plugins register synchronously during `apply()`; remote initialization,
|
||||
* authentication, and discovery are awaited inside this method. Implementations
|
||||
* should settle promptly when `options.signal` aborts.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns provider candidates with precedence ranks and opaque locators.
|
||||
*/
|
||||
list(options: SkillLookupOptions): Promise<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>
|
||||
}
|
||||
|
||||
/** Skill registry configuration. */
|
||||
export interface Config {
|
||||
/** Maximum number of completed cwd/provider catalog snapshots kept in memory. */
|
||||
collectCacheMaxEntries?: number
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
skills: SkillService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A skill provider became resolvable in the `ctx.skills` registry.
|
||||
* Consumers can observe this instead of depending on Cordis plugin load
|
||||
* order, which is concurrent for sibling plugins.
|
||||
* @param provider - the provider that just registered.
|
||||
* @mode emit
|
||||
*/
|
||||
'skill/provider-added'(provider: SkillProvider): void
|
||||
/**
|
||||
* A skill provider left the registry because its plugin fiber was disposed.
|
||||
* @param name - the registry name that no longer resolves.
|
||||
* @mode emit
|
||||
*/
|
||||
'skill/provider-removed'(name: string): void
|
||||
}
|
||||
}
|
||||
|
||||
interface IndexedCandidate {
|
||||
candidate: SkillCandidate
|
||||
provider: SkillProvider
|
||||
providerOrder: number
|
||||
localOrder: number
|
||||
}
|
||||
|
||||
interface CollectResult {
|
||||
entries: IndexedCandidate[]
|
||||
cacheable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of skill providers. It merges provider catalogs with stable
|
||||
* first-wins duplicate handling, exposes sorted model-visible summaries, and
|
||||
* loads full skill bodies on demand.
|
||||
*/
|
||||
export class SkillService extends Service {
|
||||
static Config: Schema<Config> = z.object({
|
||||
collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
|
||||
})
|
||||
|
||||
private readonly collectCacheMaxEntries: number
|
||||
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
|
||||
private readonly runtime = new Map<string, SkillDefinition>()
|
||||
private readonly collectCache = new Map<string, IndexedCandidate[]>()
|
||||
private providerRevision = 0
|
||||
private nextProviderOrder = 0
|
||||
private runtimeRevision = 0
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'skills')
|
||||
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
|
||||
assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param provider - the provider to register by `provider.name`.
|
||||
* @returns a disposer that unregisters this provider.
|
||||
*/
|
||||
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()
|
||||
yield () => {
|
||||
this.providers.delete(provider.name)
|
||||
this.invalidateCache()
|
||||
this.ctx.emit('skill/provider-removed', provider.name)
|
||||
}
|
||||
this.ctx.emit('skill/provider-added', provider)
|
||||
}.bind(this), 'skills.registerProvider()')
|
||||
return () => void 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.
|
||||
* @param skill - the complete skill definition to expose for discovery.
|
||||
* @returns a disposer that removes this runtime contribution and invalidates caches.
|
||||
*/
|
||||
register(skill: SkillRegistration): () => void {
|
||||
const normalized = normalizeRuntimeSkill(skill)
|
||||
const existing = this.runtime.get(normalized.name)
|
||||
if (existing !== undefined) {
|
||||
this.ctx.logger.warn(`runtime skill "${normalized.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()
|
||||
yield () => {
|
||||
this.runtime.delete(normalized.name)
|
||||
this.runtimeRevision += 1
|
||||
this.invalidateCache()
|
||||
}
|
||||
}.bind(this), 'skills.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* List model-invocable skill summaries for a workspace.
|
||||
* @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[]> {
|
||||
return (await this.collect(options))
|
||||
.map(entry => entry.candidate)
|
||||
.filter(skill => skill.disableModelInvocation !== true)
|
||||
.map(toSummary)
|
||||
.sort(compareSkillSummary)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one full skill definition by name.
|
||||
* @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)
|
||||
if (match === undefined) return undefined
|
||||
return await match.provider.get(match.candidate, options)
|
||||
}
|
||||
|
||||
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
|
||||
options.signal?.throwIfAborted()
|
||||
while (true) {
|
||||
const providerRevision = this.providerRevision
|
||||
const runtimeRevision = this.runtimeRevision
|
||||
const key = collectCacheKey(options, providerRevision, runtimeRevision)
|
||||
const cached = this.collectCache.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const result = await this.collectFresh(options)
|
||||
options.signal?.throwIfAborted()
|
||||
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
|
||||
if (result.cacheable) {
|
||||
this.collectCache.set(key, result.entries)
|
||||
if (this.collectCache.size > this.collectCacheMaxEntries) {
|
||||
const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
|
||||
this.collectCache.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
return result.entries
|
||||
}
|
||||
}
|
||||
|
||||
private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
const collected = await this.listAllCandidates(options)
|
||||
collected.entries.sort(compareIndexedCandidates)
|
||||
const seen = new Set<string>()
|
||||
const result: IndexedCandidate[] = []
|
||||
for (const entry of collected.entries) {
|
||||
const skill = entry.candidate
|
||||
if (seen.has(skill.name)) {
|
||||
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.source} ignored because a higher-priority skill already exists`)
|
||||
continue
|
||||
}
|
||||
seen.add(skill.name)
|
||||
result.push(entry)
|
||||
}
|
||||
return { entries: result, cacheable: collected.cacheable }
|
||||
}
|
||||
|
||||
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
options.signal?.throwIfAborted()
|
||||
const candidates: IndexedCandidate[] = []
|
||||
let cacheable = true
|
||||
let runtimeOrder = 0
|
||||
for (const skill of [...this.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
|
||||
candidates.push({
|
||||
candidate: runtimeCandidate(skill),
|
||||
provider: RUNTIME_SKILL_PROVIDER,
|
||||
providerOrder: -1,
|
||||
localOrder: runtimeOrder,
|
||||
})
|
||||
runtimeOrder += 1
|
||||
}
|
||||
for (const { provider, order } of [...this.providers.values()]) {
|
||||
let localOrder = 0
|
||||
let listed: SkillCandidate[] | undefined
|
||||
try {
|
||||
listed = 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
|
||||
for (const candidate of listed) {
|
||||
validateCandidate(candidate, provider.name)
|
||||
candidates.push({ candidate, provider, providerOrder: order, localOrder })
|
||||
localOrder += 1
|
||||
}
|
||||
}
|
||||
return { entries: candidates, cacheable }
|
||||
}
|
||||
|
||||
private invalidateCache(): void {
|
||||
this.providerRevision += 1
|
||||
this.collectCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const RUNTIME_SKILL_PROVIDER: SkillProvider = {
|
||||
name: RUNTIME_PROVIDER,
|
||||
/* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */
|
||||
list() {
|
||||
return Promise.resolve([])
|
||||
},
|
||||
get(candidate) {
|
||||
const skill = candidate.locator as SkillDefinition
|
||||
return Promise.resolve({ ...skill })
|
||||
},
|
||||
}
|
||||
|
||||
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
|
||||
return {
|
||||
...toSummary(skill),
|
||||
rank: RUNTIME_RANK,
|
||||
locator: skill,
|
||||
...skill.path !== undefined ? { path: skill.path } : {},
|
||||
...skill.metadata !== undefined ? { metadata: skill.metadata } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function validateCandidate(candidate: SkillCandidate, providerName: string): void {
|
||||
if (!SKILL_NAME.test(candidate.name)) {
|
||||
throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)
|
||||
}
|
||||
if (candidate.description.length === 0) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
|
||||
}
|
||||
if (!Number.isFinite(candidate.rank)) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)
|
||||
}
|
||||
if (candidate.provider !== providerName) {
|
||||
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeSkill(skill: SkillRegistration): SkillDefinition {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
|
||||
const { name, description, whenToUse, disableModelInvocation, source, provider, resourceBase } = skill
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...whenToUse !== undefined ? { whenToUse } : {},
|
||||
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
|
||||
source,
|
||||
provider,
|
||||
...resourceBase !== undefined ? { resourceBase } : {},
|
||||
}
|
||||
}
|
||||
|
||||
function compareSkillSummary(left: SkillSummary, right: SkillSummary): number {
|
||||
return compareCodePoints(left.name, right.name)
|
||||
}
|
||||
|
||||
function compareCodePoints(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function compareIndexedCandidates(left: IndexedCandidate, right: IndexedCandidate): number {
|
||||
return left.candidate.rank - right.candidate.rank
|
||||
|| left.providerOrder - right.providerOrder
|
||||
|| left.localOrder - right.localOrder
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
if (!Number.isInteger(value) || value < minimum) {
|
||||
throw new Error(`skill: ${name} must be an integer greater than or equal to ${minimum}`)
|
||||
}
|
||||
}
|
||||
|
||||
function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string {
|
||||
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
|
||||
}
|
||||
|
||||
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (signal === undefined) return promise
|
||||
signal.throwIfAborted()
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
cleanup()
|
||||
reject(toError(signal.reason))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void promise.then(
|
||||
(value) => {
|
||||
cleanup()
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
cleanup()
|
||||
reject(toError(error))
|
||||
},
|
||||
)
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export default SkillService
|
||||
344
packages/skill/skill/tests/skill.spec.ts
Normal file
344
packages/skill/skill/tests/skill.spec.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
|
||||
|
||||
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
provider: 'memory',
|
||||
source: 'memory',
|
||||
rank,
|
||||
locator: { content: body },
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryProvider implements SkillProvider {
|
||||
readonly name = 'memory'
|
||||
listCalls = 0
|
||||
|
||||
constructor(private candidates: SkillCandidate[]) {}
|
||||
|
||||
async list(_options: SkillLookupOptions): Promise<SkillCandidate[]> {
|
||||
this.listCalls += 1
|
||||
return this.candidates
|
||||
}
|
||||
|
||||
async get(candidate: SkillCandidate): Promise<SkillDefinition | undefined> {
|
||||
const locator = candidate.locator as { content: string }
|
||||
return { ...candidate, content: locator.content }
|
||||
}
|
||||
|
||||
replace(candidates: SkillCandidate[]): void {
|
||||
this.candidates = candidates
|
||||
}
|
||||
}
|
||||
|
||||
describe('SkillService registry', () => {
|
||||
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const provider = new MemoryProvider([
|
||||
memorySkill('z-skill', 'Z skill', 20),
|
||||
memorySkill('a-skill', 'A skill', 10),
|
||||
memorySkill('shadowed', 'Lower priority', 20),
|
||||
])
|
||||
const overrideProvider: SkillProvider = {
|
||||
name: 'override',
|
||||
async list() {
|
||||
return [{
|
||||
name: 'shadowed',
|
||||
description: 'Higher priority',
|
||||
provider: 'override',
|
||||
source: 'override',
|
||||
rank: 5,
|
||||
locator: { content: 'Override body.' },
|
||||
}]
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
}
|
||||
const disposeMemory = ctx.skills.registerProvider(provider)
|
||||
ctx.skills.registerProvider(overrideProvider)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([
|
||||
['a-skill', 'A skill', 'memory'],
|
||||
['shadowed', 'Higher priority', 'override'],
|
||||
['z-skill', 'Z skill', 'memory'],
|
||||
])
|
||||
expect((await ctx.skills.get('shadowed'))?.content).toBe('Override body.')
|
||||
const sameRankProvider: SkillProvider = {
|
||||
name: 'same-rank',
|
||||
async list() {
|
||||
return [{
|
||||
name: 'same-rank-skill',
|
||||
description: 'Same rank',
|
||||
provider: 'same-rank',
|
||||
source: 'same-rank',
|
||||
rank: 10,
|
||||
locator: { content: 'Same rank body.' },
|
||||
}]
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, content: (candidate.locator as { content: string }).content }
|
||||
},
|
||||
}
|
||||
ctx.skills.registerProvider(sameRankProvider)
|
||||
expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank')
|
||||
await expect(ctx.plugin({
|
||||
name: 'duplicate-memory',
|
||||
inject: ['skills'],
|
||||
apply(pluginCtx: Context) {
|
||||
pluginCtx.skills.registerProvider(new MemoryProvider([]))
|
||||
},
|
||||
})).rejects.toThrow('already registered')
|
||||
expect(() => ctx.skills.registerProvider({
|
||||
name: 'runtime',
|
||||
async list() {
|
||||
return []
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})).toThrow('reserved')
|
||||
|
||||
disposeMemory()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
|
||||
})
|
||||
|
||||
it('validates provider candidates and invalid registry caps', async () => {
|
||||
const defaultedService = new SkillService(new Context())
|
||||
expect(await defaultedService.list()).toEqual([])
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider({
|
||||
name: 'bad',
|
||||
async list() {
|
||||
return [memorySkill('Bad_Name', 'bad', 1)]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
await expect(ctx.skills.list()).rejects.toThrow('invalid skill name')
|
||||
|
||||
const invalidCandidates = [
|
||||
{ ...memorySkill('empty-description', '', 1), provider: 'empty-description' },
|
||||
{ ...memorySkill('bad-rank', 'Bad rank', Number.NaN), provider: 'bad-rank' },
|
||||
{ ...memorySkill('wrong-provider', 'Wrong provider', 1), provider: 'different' },
|
||||
]
|
||||
for (const candidate of invalidCandidates) {
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(SkillService)
|
||||
invalid.skills.registerProvider({
|
||||
name: candidate.name,
|
||||
async list() {
|
||||
return [candidate]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
await expect(invalid.skills.list()).rejects.toThrow('skill provider')
|
||||
}
|
||||
|
||||
await expect(new Context().plugin(SkillService, { collectCacheMaxEntries: 1.5 })).rejects.toThrow('collectCacheMaxEntries')
|
||||
})
|
||||
|
||||
it('sorts model-visible summaries without locale-sensitive collation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider(new MemoryProvider([
|
||||
memorySkill('z-skill', 'Z skill', 10),
|
||||
memorySkill('a-skill', 'A skill', 10),
|
||||
]))
|
||||
const localeCompare = vi.spyOn(String.prototype, 'localeCompare')
|
||||
const sort = vi.spyOn(Array.prototype, 'sort')
|
||||
|
||||
try {
|
||||
const skills = await ctx.skills.list()
|
||||
expect(skills.map(skill => skill.name)).toEqual(['a-skill', 'z-skill'])
|
||||
expect(localeCompare).not.toHaveBeenCalled()
|
||||
|
||||
const summaryComparator = sort.mock.calls.at(-1)?.[0]
|
||||
expect(summaryComparator).toBeTypeOf('function')
|
||||
expect(summaryComparator?.(skills[0], skills[0])).toBe(0)
|
||||
} finally {
|
||||
sort.mockRestore()
|
||||
localeCompare.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('caches provider discovery, skips failing providers, and invalidates on runtime skills', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 })
|
||||
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
|
||||
ctx.skills.registerProvider(provider)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
|
||||
provider.replace([memorySkill('second-skill', 'Second', 10)])
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
|
||||
|
||||
const disposeRuntime = ctx.skills.register({
|
||||
name: 'runtime-skill',
|
||||
description: 'Runtime',
|
||||
source: 'runtime',
|
||||
resourceBase: { kind: 'opaque', description: 'runtime memory' },
|
||||
path: 'memory://runtime-skill',
|
||||
metadata: { owner: 'tests' },
|
||||
content: 'Runtime body.',
|
||||
})
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['runtime-skill', 'second-skill'])
|
||||
expect(await ctx.skills.get('runtime-skill')).toMatchObject({
|
||||
content: 'Runtime body.',
|
||||
path: 'memory://runtime-skill',
|
||||
metadata: { owner: 'tests' },
|
||||
})
|
||||
disposeRuntime()
|
||||
await ctx.skills.list({ cwd: '/tmp/first-cache-key' })
|
||||
await ctx.skills.list({ cwd: '/tmp/second-cache-key' })
|
||||
|
||||
let fail = true
|
||||
let flakyCalls = 0
|
||||
ctx.skills.registerProvider({
|
||||
name: 'flaky',
|
||||
async list() {
|
||||
flakyCalls += 1
|
||||
if (fail) throw new Error('transient discovery failure')
|
||||
return [{ ...memorySkill('flaky-skill', 'Flaky', 10), provider: 'flaky' }]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
|
||||
expect(flakyCalls).toBe(1)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
|
||||
expect(flakyCalls).toBe(2)
|
||||
fail = false
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flaky-skill', 'second-skill'])
|
||||
expect(flakyCalls).toBe(3)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flaky-skill', 'second-skill'])
|
||||
expect(flakyCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('abandons an in-flight catalog when provider registrations change', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let markStarted: (() => void) | undefined
|
||||
let release: (() => void) | undefined
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const dispose = ctx.skills.registerProvider({
|
||||
name: 'delayed',
|
||||
async list() {
|
||||
markStarted?.()
|
||||
await gate
|
||||
return [{ ...memorySkill('stale-skill', 'Stale', 10), provider: 'delayed' }]
|
||||
},
|
||||
async get(candidate) {
|
||||
return { ...candidate, content: 'Stale body.' }
|
||||
},
|
||||
})
|
||||
|
||||
const pending = ctx.skills.list()
|
||||
await started
|
||||
dispose()
|
||||
release?.()
|
||||
|
||||
expect(await pending).toEqual([])
|
||||
})
|
||||
|
||||
it('stops waiting for discovery when its lookup signal aborts', 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<SkillCandidate[]>((resolve) => {
|
||||
release = () => { resolve([]) }
|
||||
})
|
||||
ctx.skills.registerProvider({
|
||||
name: 'uncooperative',
|
||||
list(options) {
|
||||
seenSignal = options.signal
|
||||
markStarted?.()
|
||||
return held
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const reason = 'discovery cancelled'
|
||||
const pending = ctx.skills.list({ signal: controller.signal })
|
||||
const outcome = pending.then(
|
||||
() => 'resolved',
|
||||
(error: unknown) => error instanceof Error && error.message === reason ? 'aborted' : 'other-error',
|
||||
)
|
||||
await started
|
||||
controller.abort(reason)
|
||||
|
||||
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('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)
|
||||
expect(() => ctx.skills.register({ name: 'Bad_Name', description: 'Bad', source: 'runtime', content: 'bad' })).toThrow('invalid skill name')
|
||||
expect(() => ctx.skills.register({ name: 'no-description', description: '', source: 'runtime', content: 'bad' })).toThrow('requires a description')
|
||||
expect(await ctx.skills.get('missing-skill')).toBeUndefined()
|
||||
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
|
||||
|
||||
const disposeFirst = ctx.skills.register({ name: 'same-skill', description: 'First', source: 'runtime', content: 'first' })
|
||||
const disposeSecond = ctx.skills.register({ name: 'same-skill', description: 'Second', source: 'runtime', content: 'second' })
|
||||
disposeSecond()
|
||||
expect((await ctx.skills.get('same-skill'))?.description).toBe('First')
|
||||
disposeFirst()
|
||||
expect(await ctx.skills.get('same-skill')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
13
packages/skill/skill/tsconfig.json
Normal file
13
packages/skill/skill/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" }
|
||||
]
|
||||
}
|
||||
21
packages/skill/tool-skill/README.md
Normal file
21
packages/skill/tool-skill/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-tool-skill
|
||||
|
||||
The model-facing skill catalog and `skill` tool.
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
## Tool: `skill`
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | string (required) | Exact kebab-case skill name from the available skills listing. |
|
||||
|
||||
Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers can resolve the right winning skill. A successful call returns one text tool result with `<skill_content name="...">`, containing `<skill_resources>` followed by `<skill_instructions>`. Resource guidance resolves paths or URLs explicitly referenced by the loaded instructions against `resourceBase`; referenced scripts, references, and assets load only when needed, and the tool does not enumerate a skill directory. Local filesystem skills provide a base directory, while remote or embedded providers can provide a URL or opaque provider-managed guidance. A name that cannot be resolved reports that the skill is unknown or no longer available; invalid names and skills marked `disableModelInvocation: true` retain distinct `isError` results.
|
||||
|
||||
The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context.
|
||||
42
packages/skill/tool-skill/package.json
Normal file
42
packages/skill/tool-skill/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-skill",
|
||||
"description": "Model-facing skill loading tool for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
152
packages/skill/tool-skill/src/index.ts
Normal file
152
packages/skill/tool-skill/src/index.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Session-prefix skill catalog and model-facing `skill` loader tool.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-skill
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { assertNever, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
|
||||
export const name = 'tool-skill'
|
||||
export const inject = ['tools', 'skills']
|
||||
|
||||
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
|
||||
|
||||
/** Model-facing skill catalog configuration. */
|
||||
export interface Config {
|
||||
/** Maximum normalized description length rendered in the session catalog; minimum 3. */
|
||||
catalogDescriptionMaxLength?: number
|
||||
}
|
||||
|
||||
/** Validate and default the model-facing skill catalog configuration. */
|
||||
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. */
|
||||
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.',
|
||||
parameters: {
|
||||
name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (!isSkillName(args.name)) {
|
||||
throw new Error(`invalid skill name "${args.name}"`)
|
||||
}
|
||||
const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd, signal: exec.signal })
|
||||
if (!skill) {
|
||||
throw new Error(`skill "${args.name}" is unknown or no longer available`)
|
||||
}
|
||||
if (skill.disableModelInvocation === true) {
|
||||
throw new Error(`skill "${args.name}" is not available for model invocation`)
|
||||
}
|
||||
return [{ type: 'text', text: renderSkillContent(skill) }]
|
||||
},
|
||||
presentCall(args) {
|
||||
return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
|
||||
},
|
||||
})
|
||||
ctx.tools.register(skillTool)
|
||||
}
|
||||
|
||||
function renderSkillContent(skill: SkillDefinition): string {
|
||||
const resourceHint = renderResourceHint(skill)
|
||||
return [
|
||||
`<skill_content name="${escapeAttr(skill.name)}">`,
|
||||
'<skill_resources>',
|
||||
...resourceHint,
|
||||
'</skill_resources>',
|
||||
'',
|
||||
'<skill_instructions>',
|
||||
skill.content,
|
||||
'</skill_instructions>',
|
||||
'</skill_content>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function renderResourceHint(skill: SkillDefinition): string[] {
|
||||
const base = skill.resourceBase
|
||||
if (base === undefined) {
|
||||
return [
|
||||
`Resources for this skill are managed by provider "${escapeText(skill.provider)}".`,
|
||||
'Load referenced resources only as needed.',
|
||||
]
|
||||
}
|
||||
switch (base.kind) {
|
||||
case 'directory':
|
||||
return [
|
||||
`Base directory for this skill: ${escapeText(base.path)}`,
|
||||
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
|
||||
]
|
||||
case 'url':
|
||||
return [
|
||||
`Base URL for this skill: ${escapeText(base.url)}`,
|
||||
'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.',
|
||||
]
|
||||
case 'opaque':
|
||||
return [
|
||||
`Resources for this skill: ${escapeText(base.description)}`,
|
||||
'Load referenced resources only as needed.',
|
||||
]
|
||||
default:
|
||||
return assertNever(base, 'SkillResourceBase.kind')
|
||||
}
|
||||
}
|
||||
|
||||
function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): Message {
|
||||
const entries = skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'<system-reminder>',
|
||||
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
...entries,
|
||||
'</available_skills>',
|
||||
'',
|
||||
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function catalogDescription(value: string, maxLength: number): string {
|
||||
const normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||
const truncated = normalized.length <= maxLength
|
||||
? normalized
|
||||
: `${normalized.slice(0, maxLength - 3)}...`
|
||||
return escapeText(truncated)
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
if (!Number.isInteger(value) || value < minimum) {
|
||||
throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`)
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<')
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
}
|
||||
275
packages/skill/tool-skill/tests/tool-skill.spec.ts
Normal file
275
packages/skill/tool-skill/tests/tool-skill.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
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'
|
||||
|
||||
async function tempDir(name: string): Promise<string> {
|
||||
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {
|
||||
const dir = join(root, name)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
|
||||
}
|
||||
|
||||
async function setup(home: string, config: toolSkill.Config = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
await ctx.plugin(toolSkill, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function agentForCwd(cwd: string): never {
|
||||
return { session: { header: { cwd } } } as never
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', agentForCwd(cwd), empty, signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
describe('dsh-tool-skill', () => {
|
||||
it('registers the skill tool schema and removes it on dispose', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const home = await tempDir('tool-schema')
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
ctx.skills.register({ name: 'lifecycle-skill', description: 'Lifecycle', source: 'runtime', content: 'body' })
|
||||
|
||||
const fiber = await ctx.plugin(toolSkill)
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
|
||||
expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
|
||||
expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({
|
||||
card: 'generic',
|
||||
title: 'Load skill project-skill',
|
||||
kind: 'read',
|
||||
rawInput: 'project-skill',
|
||||
})
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toEqual([])
|
||||
expect(await composePrefix(ctx, '/workspace')).toEqual([])
|
||||
|
||||
toolSkill.apply(ctx)
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
|
||||
})
|
||||
|
||||
it('forwards the session-prefix abort signal to skill discovery', async () => {
|
||||
const home = await tempDir('tool-prefix-signal')
|
||||
const ctx = await setup(home)
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.skills.registerProvider({
|
||||
name: 'signal-probe',
|
||||
async list(options) {
|
||||
seenSignal = options.signal
|
||||
return []
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
|
||||
await composePrefix(ctx, '/workspace', controller.signal)
|
||||
|
||||
expect(seenSignal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('contributes a stable name-and-description catalog through the session prefix', async () => {
|
||||
const home = await tempDir('tool-catalog')
|
||||
const ctx = await setup(home, { catalogDescriptionMaxLength: 50 })
|
||||
ctx.skills.register({
|
||||
name: 'z-skill',
|
||||
description: 'Long description '.repeat(5),
|
||||
whenToUse: 'Never render this routing hint.',
|
||||
source: 'secret-source',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'directory', path: '/secret/path' },
|
||||
content: 'Secret body.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'a-skill',
|
||||
description: 'Use {{placeholder}} <safely> & carefully.',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'A body.',
|
||||
})
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
|
||||
...await next(),
|
||||
])
|
||||
|
||||
const prefix = await composePrefix(ctx, '/workspace')
|
||||
|
||||
expect(prefix).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'<system-reminder>',
|
||||
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
'- `a-skill`: Use {{placeholder}} <safely> & carefully.',
|
||||
'- `z-skill`: Long description Long description Long descript...',
|
||||
'</available_skills>',
|
||||
'',
|
||||
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
},
|
||||
{ role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
|
||||
])
|
||||
const rendered = JSON.stringify(prefix[0])
|
||||
expect(rendered).not.toContain('whenToUse')
|
||||
expect(rendered).not.toContain('secret-source')
|
||||
expect(rendered).not.toContain('/secret/path')
|
||||
expect(rendered).not.toContain('Secret body')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
|
||||
})
|
||||
|
||||
it('does not contribute a session-prefix message when no skills are available', async () => {
|
||||
const home = await tempDir('tool-empty-catalog')
|
||||
const ctx = await setup(home)
|
||||
|
||||
expect(await composePrefix(ctx, '/workspace')).toEqual([])
|
||||
})
|
||||
|
||||
it('validates the catalog description cap', async () => {
|
||||
const home = await tempDir('tool-invalid-catalog-cap')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
|
||||
await expect(ctx.plugin(toolSkill, { catalogDescriptionMaxLength: 2 })).rejects.toThrow('greater than or equal to 3')
|
||||
})
|
||||
|
||||
it('loads a skill for the calling agent cwd', async () => {
|
||||
const home = await tempDir('tool-load')
|
||||
const project = await tempDir('tool-project')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.')
|
||||
const ctx = await setup(home)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('c1'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'project-skill' },
|
||||
agent: { session: { header: { cwd: project } } } as never,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const block = result.content[0]
|
||||
expect(block?.type).toBe('text')
|
||||
if (block?.type !== 'text') throw new Error('expected text skill result')
|
||||
expect(block.text).toBe([
|
||||
'<skill_content name="project-skill">',
|
||||
'<skill_resources>',
|
||||
`Base directory for this skill: ${join(project, '.dsh/skills/project-skill')}`,
|
||||
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
|
||||
'</skill_resources>',
|
||||
'',
|
||||
'<skill_instructions>',
|
||||
'Project instructions.',
|
||||
'</skill_instructions>',
|
||||
'</skill_content>',
|
||||
].join('\n'))
|
||||
expect(block.text).not.toContain('# Skill:')
|
||||
})
|
||||
|
||||
it('renders provider-managed resource hints for non-local skills', async () => {
|
||||
const home = await tempDir('tool-resource-hints')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'opaque-skill',
|
||||
description: 'Opaque skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'opaque', description: 'runtime memory' },
|
||||
content: 'Opaque instructions.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'url-skill',
|
||||
description: 'URL skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'url', url: 'https://skills.example.test/url-skill' },
|
||||
content: 'URL instructions.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'provider-skill',
|
||||
description: 'Provider skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'Provider instructions.',
|
||||
})
|
||||
|
||||
const opaque = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } })
|
||||
const url = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } })
|
||||
const provider = await ctx.tools.execute({ callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } })
|
||||
|
||||
if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') {
|
||||
throw new Error('expected text tool results')
|
||||
}
|
||||
expect(opaque.content[0].text).toContain('<skill_resources>\nResources for this skill: runtime memory\nLoad referenced resources only as needed.\n</skill_resources>')
|
||||
expect(url.content[0].text).toContain('<skill_resources>\nBase URL for this skill: https://skills.example.test/url-skill\nResolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.\n</skill_resources>')
|
||||
expect(provider.content[0].text).toContain('<skill_resources>\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n</skill_resources>')
|
||||
})
|
||||
|
||||
it('fails loud on an unknown resource base kind', async () => {
|
||||
const home = await tempDir('tool-resource-assert-never')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'rogue-resource-skill',
|
||||
description: 'Rogue resource skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'future' } as never,
|
||||
content: 'Rogue instructions.',
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
const block = result.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(block.text).toContain('unreachable variant')
|
||||
})
|
||||
|
||||
it('returns isError for unknown, invalid, and model-disabled skills', async () => {
|
||||
const home = await tempDir('tool-errors')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
|
||||
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
|
||||
const ctx = await setup(home)
|
||||
|
||||
const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
|
||||
const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
|
||||
const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
|
||||
|
||||
expect(unknown.isError).toBe(true)
|
||||
expect(invalid.isError).toBe(true)
|
||||
expect(disabled.isError).toBe(true)
|
||||
const unknownBlock = unknown.content[0]
|
||||
if (unknownBlock?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available')
|
||||
})
|
||||
})
|
||||
17
packages/skill/tool-skill/tsconfig.json
Normal file
17
packages/skill/tool-skill/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../skill" },
|
||||
{ "path": "../../core/tools" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user