Add skill discovery and loading

This commit is contained in:
Yichen Jiang
2026-06-25 23:35:13 +08:00
parent 329371a529
commit 45be662e85
28 changed files with 1234 additions and 27 deletions

View File

@@ -0,0 +1,41 @@
# @deepseek-ai/dsh-skill
Agent skill discovery and model-facing skill guidance.
## Service: `SkillService` (ctx key: `skills`)
### Public API
- `ctx.skills.list({ cwd? })` Returns model-invocable skill summaries for the current workspace.
- `ctx.skills.get(name, { cwd? })` Returns the full skill, including disabled-for-model skills.
- `ctx.skills.register(skill): () => void` Registers a runtime skill, disposed with the calling fiber.
### Discovery
Default roots are resolved in this conflict priority order:
| Source | Path |
|---|---|
| Project DSH | `<projectRoot>/.dsh/skills` |
| Project agents | `<projectRoot>/.agents/skills` |
| Runtime | `ctx.skills.register(...)` |
| User DSH | `~/.dsh/skills` |
| User agents | `~/.agents/skills` |
| Extra | `Config.extraRoots` |
| System | `~/.dsh/skills/.system` |
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips `.system` during normal user scanning so system skills are read exactly once. Same-name skills keep the highest-priority copy, then model-visible summaries are sorted by skill name for stable prompts and provider prefix-cache friendliness.
Discovery is memoized per resolved root set and runtime-skill revision. Runtime `register()` and disposer calls invalidate the cache; disk-only changes are picked up on the next invalidation or process restart.
## 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 requires `name` and `description`; `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case.
## Prompt Integration
The service listens on `agent/request` and appends a short `## Skills` listing to the request system prompt for the calling agent's cwd. The listing contains only stable routing metadata (`name`, `source`, `description`, and optional `whenToUse`), not skill bodies or local absolute paths. `description` and `whenToUse` are whitespace-normalized and capped in the listing so one pathological skill cannot bloat every model request. Models load full instructions through the `skill` tool.
## System Skills
On startup, the service ensures bundled system skills exist under `~/.dsh/skills/.system` unless `installSystemSkills: false` is configured. Project, runtime, user, and extra-root skills can override system skills by name.

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-skill",
"description": "Agent skill discovery and prompt listing 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",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"yaml": "^2.4.2"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,425 @@
/**
* Agent skill discovery and prompt listing.
*
* Skills are progressive-disclosure instructions: the model sees only a short
* listing in the system prompt, then calls the `skill` tool to load the full
* `SKILL.md` body when a task matches.
*
* @module @deepseek-ai/dsh-skill
*/
import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { homedir } from 'node:os'
import { Context, Service } from 'cordis'
import { parse as parseYaml } from 'yaml'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-agent'
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const MAX_PROMPT_FIELD_LENGTH = 500
export function isSkillName(name: string): boolean {
return SKILL_NAME.test(name)
}
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'extra' | 'system'
export interface SkillSummary {
name: string
description: string
whenToUse?: string
disableModelInvocation?: boolean
directory: string
source: SkillSource
}
export interface SkillDefinition extends SkillSummary {
content: string
path?: string
metadata?: Record<string, unknown>
}
export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & {
disableModelInvocation?: boolean
}
export interface SkillLookupOptions {
cwd?: string | undefined
}
export interface Config {
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Shared agent config root. Defaults to `~/.agents`. */
agentsHome?: string
/** Extra skill roots, scanned after user roots and before system skills. */
extraRoots?: string[]
/** Ensure bundled system skills exist under `<dshHome>/skills/.system`. Defaults true. */
installSystemSkills?: boolean
}
declare module 'cordis' {
interface Context {
skills: SkillService
}
}
interface SkillRoot {
path: string
source: SkillSource
skipSystem?: boolean
}
const SYSTEM_SKILLS: SkillDefinition[] = [
{
name: 'dsh-plugin-creator',
description: 'Create or update DeepSeek Harness Cordis plugins and packages.',
directory: 'system://dsh-plugin-creator',
source: 'system',
content: [
'Use this skill to create DeepSeek Harness plugins that fit the repository architecture.',
'',
'Prefer Cordis services, plugin packages, effect-scoped registrations, and existing extension seams over loop changes.',
'When adding a swappable capability, design the interface/implementation/consumer split first.',
'Every registry or registration path needs disposal/HMR coverage.',
'Update package docs, architecture docs, package graph references, and generated catalogs when public surfaces change.',
].join('\n'),
},
{
name: 'dsh-skill-creator',
description: 'Create or update DeepSeek Harness SKILL.md instructions.',
whenToUse: 'Use when writing reusable agent instructions for DeepSeek Harness or adding system/project/user skills.',
directory: 'system://dsh-skill-creator',
source: 'system',
content: [
'Use this skill to write focused DeepSeek Harness skills.',
'',
'A skill is a directory `<name>/SKILL.md` or a flat `<name>.md` file with YAML frontmatter.',
'Frontmatter must include kebab-case `name` and a concise `description` that tells the model when to load it.',
'Use optional `whenToUse` for extra routing signal and `disableModelInvocation: true` for user-only skills.',
'Keep the body procedural, evidence-oriented, and scoped to the workflow the skill owns.',
].join('\n'),
},
]
export class SkillService extends Service {
private readonly dshHome: string
private readonly agentsHome: string
private readonly extraRoots: string[]
private readonly installSystemSkills: boolean
private readonly runtime = new Map<string, SkillDefinition>()
private readonly collectCache = new Map<string, Promise<SkillDefinition[]>>()
private runtimeRevision = 0
private systemReady: Promise<void> | undefined
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'skills')
this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
this.agentsHome = resolve(config.agentsHome ?? join(homedir(), '.agents'))
this.extraRoots = (config.extraRoots ?? []).map(root => resolve(root))
this.installSystemSkills = config.installSystemSkills ?? true
if (this.installSystemSkills) {
const systemRoot = join(this.dshHome, 'skills/.system')
this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => {
this.ctx.logger.warn(`failed to install bundled system skills under ${systemRoot}: ${errorMessage(error)}`)
})
}
ctx.on('agent/request', async (agent, _turn, _step, _request, next) => {
const listing = await this.renderModelListing({ cwd: agent.session.header.cwd })
const result = await next()
if (listing.length > 0) appendSystem(result, listing)
return result
})
}
register(skill: SkillRegistration): () => void {
const normalized = normalizeSkill(skill)
const dispose = this.ctx.effect(function* (this: SkillService) {
this.runtime.set(normalized.name, normalized)
this.invalidateCache()
yield () => {
this.runtime.delete(normalized.name)
this.invalidateCache()
}
}.bind(this), 'skills.register()')
return () => void dispose()
}
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
return (await this.collect(options))
.filter(skill => skill.disableModelInvocation !== true)
.map(toSummary)
.sort(compareSummary)
}
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
if (!isSkillName(name)) return undefined
return (await this.collect(options)).find(skill => skill.name === name)
}
async renderModelListing(options: SkillLookupOptions = {}): Promise<string> {
const skills = await this.list(options)
if (skills.length === 0) return ''
const entries = skills.map((skill) => {
const lines = [
`<skill name="${escapeAttr(skill.name)}" source="${escapeAttr(skill.source)}">`,
`description: ${promptLine(skill.description)}`,
...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse)}`] : [],
'</skill>',
]
return lines.join('\n')
}).join('\n')
return [
'## Skills',
'Available skills are listed below. Load a skill with the `skill` tool before following its instructions; do not infer or follow instructions from a skill body that has not been loaded.',
'<available_skills>',
entries,
'</available_skills>',
].join('\n')
}
private async collect(options: SkillLookupOptions): Promise<SkillDefinition[]> {
await this.ensureSystemSkills()
const roots = await this.roots(options.cwd)
const key = collectCacheKey(roots, this.runtimeRevision)
const cached = this.collectCache.get(key)
if (cached !== undefined) return cached
const collected = this.collectFresh(roots)
this.collectCache.set(key, collected)
return collected
}
private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise<SkillDefinition[]> {
const seen = new Set<string>()
const result: SkillDefinition[] = []
const add = (skill: SkillDefinition): void => {
if (seen.has(skill.name)) {
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.directory} ignored because a higher-priority skill already exists`)
return
}
seen.add(skill.name)
result.push(skill)
}
for (const root of roots.project) {
for (const skill of await discoverRoot(root, this.ctx)) add(skill)
}
for (const skill of [...this.runtime.values()].sort((a, b) => a.name.localeCompare(b.name))) add(skill)
for (const root of roots.shared) {
for (const skill of await discoverRoot(root, this.ctx)) add(skill)
}
return result
}
private async roots(cwd: string | undefined): Promise<{ project: SkillRoot[]; shared: SkillRoot[] }> {
const project: SkillRoot[] = []
if (cwd !== undefined) {
const projectRoot = await findProjectRoot(resolve(cwd))
project.push(
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh' },
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents' },
)
}
const shared: SkillRoot[] = [
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', skipSystem: true },
{ path: join(this.agentsHome, 'skills'), source: 'user-agents' },
...this.extraRoots.map(path => ({ path, source: 'extra' as const })),
{ path: join(this.dshHome, 'skills/.system'), source: 'system' },
]
return { project, shared }
}
private ensureSystemSkills(): Promise<void> {
return this.systemReady ?? Promise.resolve()
}
private invalidateCache(): void {
this.runtimeRevision += 1
this.collectCache.clear()
}
}
async function writeSystemSkills(systemRoot: string, ctx: Context): Promise<void> {
await mkdir(systemRoot, { recursive: true })
await Promise.all(SYSTEM_SKILLS.map(async (skill) => {
const dir = join(systemRoot, skill.name)
const file = join(dir, 'SKILL.md')
try {
await access(file)
return
} catch {
// Expected first-run path: the bundled system skill has not been installed.
}
await mkdir(dir, { recursive: true })
await writeFile(file, renderSkillFile(skill))
ctx.logger.debug(`installed system skill ${skill.name} at ${file}`)
}))
}
function renderSkillFile(skill: SkillDefinition): string {
const frontmatter = [
'---',
`name: ${skill.name}`,
`description: ${skill.description}`,
...skill.whenToUse ? [`whenToUse: ${skill.whenToUse}`] : [],
'---',
'',
]
return `${frontmatter.join('\n')}${skill.content}\n`
}
async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinition[]> {
let entries
try {
entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
} catch {
return []
}
const skills: SkillDefinition[] = []
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (root.skipSystem && entry.name === '.system') continue
const fullPath = join(root.path, entry.name)
const parsed = entry.isDirectory()
? await parseSkillFile(join(fullPath, 'SKILL.md'), fullPath, root.source, ctx)
: entry.isFile() && entry.name.endsWith('.md')
? await parseSkillFile(fullPath, root.path, root.source, ctx)
: undefined
if (parsed) skills.push(parsed)
}
return skills
}
async function parseSkillFile(path: string, directory: string, source: SkillSource, ctx: Context): Promise<SkillDefinition | undefined> {
let raw: string
try {
raw = await readFile(path, 'utf8')
} catch {
return undefined
}
const parsed = parseFrontmatter(raw)
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),
directory,
path,
source,
content: parsed.body.trim(),
}
}
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
if (!raw.startsWith('---\n')) return undefined
const end = raw.indexOf('\n---', 4)
if (end < 0) return undefined
const yaml = raw.slice(4, end)
const bodyStart = raw.indexOf('\n', end + 4)
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: bodyStart < 0 ? '' : raw.slice(bodyStart + 1) }
}
async function findProjectRoot(cwd: string): Promise<string> {
let current = cwd
while (true) {
try {
await access(join(current, '.git'))
return current
} catch {
// Continue walking upward until a git root is found.
}
const parent = dirname(current)
if (parent === current) return cwd
current = parent
}
}
function normalizeSkill(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, source: skill.source }
}
function toSummary(skill: SkillDefinition): SkillSummary {
const { name, description, whenToUse, disableModelInvocation, directory, source } = skill
return {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
directory,
source,
}
}
function compareSummary(left: SkillSummary, right: SkillSummary): number {
return left.name.localeCompare(right.name)
}
function promptLine(value: string): string {
const normalized = value.replaceAll(/\s+/g, ' ').trim()
if (normalized.length <= MAX_PROMPT_FIELD_LENGTH) return normalized
return `${normalized.slice(0, MAX_PROMPT_FIELD_LENGTH - 3)}...`
}
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 escapeAttr(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
}
function collectCacheKey(roots: { project: SkillRoot[]; shared: SkillRoot[] }, runtimeRevision: number): string {
return JSON.stringify({ runtimeRevision, roots })
}
function errorMessage(error: unknown): string {
return String(error)
}
function appendSystem(request: GenerateOptions, text: string): void {
request.system = [request.system ?? '', text].filter(part => part.length > 0).join('\n\n')
}
export default SkillService

View File

@@ -0,0 +1,342 @@
import { describe, expect, it } from 'vitest'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import SkillService from '@deepseek-ai/dsh-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 = '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`)
}
describe('SkillService', () => {
it('discovers project, user, agents, and system skill roots in priority order', async () => {
const home = await tempDir('skill-home')
const agentsHome = await tempDir('agents-home')
const project = await tempDir('skill-project')
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(home, '.dsh/skills/.system'), 'same', 'system skill')
await writeSkill(join(agentsHome, '.agents/skills'), 'same', 'user agents skill')
await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill')
await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill')
await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill')
await writeSkill(join(home, '.dsh/skills/.system'), 'system-only', 'system only')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(agentsHome, '.agents'), installSystemSkills: false })
const skills = await ctx.skills.list({ cwd: join(project, 'src') })
expect(skills.map(skill => [skill.name, skill.description])).toEqual([
['same', 'project dsh skill'],
['system-only', 'system only'],
])
expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
})
it('sorts the final model-visible list by skill name after priority conflict resolution', async () => {
const home = await tempDir('skill-sorted-home')
const agentsHome = await tempDir('skill-sorted-agents')
const project = await tempDir('skill-sorted-project')
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(project, '.dsh/skills'), 'z-project', 'Project skill')
await writeSkill(join(home, '.dsh/skills'), 'm-user', 'User skill')
await writeSkill(join(home, '.dsh/skills/.system'), 'a-system', 'System skill')
await writeSkill(join(home, '.dsh/skills/.system'), 'm-user', 'Shadowed system skill')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(agentsHome, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list({ cwd: project })).map(skill => [skill.name, skill.description])).toEqual([
['a-system', 'System skill'],
['m-user', 'User skill'],
['z-project', 'Project skill'],
])
})
it('gives project skills priority over runtime skills while runtime overrides user and system skills', async () => {
const home = await tempDir('skill-runtime-priority')
const project = await tempDir('skill-runtime-project')
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins')
await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses')
await writeSkill(join(home, '.dsh/skills/.system'), 'runtime-name', 'System loses')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
ctx.skills.register({
name: 'project-name',
description: 'Runtime loses to project',
content: 'Runtime body.',
directory: 'memory://project-name',
source: 'runtime',
})
ctx.skills.register({
name: 'runtime-name',
description: 'Runtime wins',
content: 'Runtime body.',
directory: 'memory://runtime-name',
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('does not scan .system twice through the user dsh root', async () => {
const home = await tempDir('skill-system')
await writeSkill(join(home, '.dsh/skills/.system'), 'builtin', 'builtin skill')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['builtin'])
})
it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
const home = await tempDir('skill-flat')
await writeFlatSkill(join(home, '.dsh/skills'), 'flat-skill', 'flat description', 'Flat instructions.')
await writeFile(join(home, '.dsh/skills/bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
await writeFile(join(home, '.dsh/skills/missing-description.md'), '---\nname: missing-description\n---\n\nbad')
await writeFile(join(home, '.dsh/skills/no-frontmatter.md'), 'No frontmatter.')
await writeFile(join(home, '.dsh/skills/open-frontmatter.md'), '---\nname: open-frontmatter')
await writeFile(join(home, '.dsh/skills/non-object.md'), '---\n[]\n---\n\nbad')
await writeFile(join(home, '.dsh/skills/no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
await writeFile(join(home, '.dsh/skills/notes.txt'), 'ignored')
await mkdir(join(home, '.dsh/skills/not-a-skill'), { recursive: true })
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'hidden description', 'Hidden.')
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body'])
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
})
it('renders no model listing when no model-invocable skills exist', async () => {
const home = await tempDir('skill-empty-listing')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect(await ctx.skills.renderModelListing()).toBe('')
const request = { model: 'm', messages: [], system: 'base' }
const result = await ctx.waterfall('agent/request', {
session: { header: { cwd: home } },
} as never, 1, 1, request, () => Promise.resolve(request))
expect(result.system).toBe('base')
})
it('installs system skills into the DSH home without overwriting existing files', async () => {
const home = await tempDir('skill-install')
const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
await mkdir(join(home, '.dsh/skills/.system/dsh-plugin-creator'), { recursive: true })
await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Custom system skill\n---\n\nCustom body.\n')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
['dsh-plugin-creator', 'Custom system skill'],
['dsh-skill-creator', 'Create or update DeepSeek Harness SKILL.md instructions.'],
])
expect(await readFile(existing, 'utf8')).toContain('Custom body.')
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator')
})
it('degrades when bundled system skill installation fails', async () => {
const home = await tempDir('skill-install-fail')
await writeFile(join(home, '.dsh'), 'not a directory')
await writeSkill(join(home, '.agents/skills'), 'fallback-skill', 'Fallback skill')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['fallback-skill'])
})
it('memoizes disk discovery until runtime skill registrations change', async () => {
const home = await tempDir('skill-cache')
await writeSkill(join(home, '.dsh/skills'), 'initial-skill', 'Initial skill')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill'])
await writeSkill(join(home, '.dsh/skills'), 'late-skill', 'Late skill')
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill'])
const dispose = ctx.skills.register({
name: 'runtime-skill',
description: 'runtime',
content: 'Runtime body.',
directory: 'memory://runtime',
source: 'runtime',
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill', 'late-skill', 'runtime-skill'])
dispose()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill', 'late-skill'])
})
it('includes extra roots, optional metadata, and explicit false disable flags', async () => {
const home = await tempDir('skill-extra')
const extra = await tempDir('skill-extra-root')
await writeFile(join(extra, 'extra-skill.md'), [
'---',
'name: extra-skill',
'description: Extra skill',
'whenToUse: For extra-root tests',
'disableModelInvocation: false',
'metadata:',
' owner: tests',
'---',
'',
'Extra body.',
].join('\n'))
const ctx = new Context()
await ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
extraRoots: [extra],
installSystemSkills: false,
})
expect(await ctx.skills.list()).toEqual([{
name: 'extra-skill',
description: 'Extra skill',
whenToUse: 'For extra-root tests',
disableModelInvocation: false,
directory: extra,
source: 'extra',
}])
expect((await ctx.skills.get('extra-skill'))?.metadata).toEqual({ owner: 'tests' })
expect(await ctx.skills.renderModelListing()).toContain('whenToUse: For extra-root tests')
})
it('bounds prompt listing fields without changing stored skill content', async () => {
const home = await tempDir('skill-prompt-bounds')
const longDescription = 'a'.repeat(600)
await writeSkill(join(home, '.dsh/skills'), 'long-skill', longDescription, 'Full body.')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const listing = await ctx.skills.renderModelListing()
expect(listing).toContain(`${'a'.repeat(497)}...`)
expect(listing).not.toContain('a'.repeat(600))
expect((await ctx.skills.get('long-skill'))?.description).toBe(longDescription)
})
it('adds skill guidance through the agent/request waterfall without including bodies', async () => {
const home = await tempDir('skill-guidance')
await writeSkill(join(home, '.dsh/skills'), 'research-helper', 'Research helper', 'Long body that must not be listed.')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const request = await ctx.waterfall('agent/request', {
session: { header: { cwd: home } },
} as never, 1, 1, { model: 'm', messages: [], system: 'base' }, () => Promise.resolve({ model: 'm', messages: [], system: 'base' }))
expect(request.system ?? '').toContain('## Skills\n')
expect(request.system ?? '').toContain('research-helper')
expect(request.system ?? '').toContain('source="project-dsh"')
expect(request.system ?? '').not.toContain(home)
expect(request.system ?? '').not.toContain('Long body')
expect((request.system ?? '').match(/## Skills/g)).toHaveLength(1)
const sameObject = { model: 'm', messages: [], system: 'base' }
const sameObjectResult = await ctx.waterfall('agent/request', {
session: { header: { cwd: home } },
} as never, 1, 1, sameObject, () => Promise.resolve(sameObject))
expect(sameObjectResult.system).toContain('## Skills')
const requestWithoutBase = await ctx.waterfall('agent/request', {
session: { header: { cwd: home } },
} as never, 1, 1, { model: 'm', messages: [] }, () => Promise.resolve({ model: 'm', messages: [] }))
expect(requestWithoutBase.system).toContain('## Skills')
const copyCtx = new Context()
await copyCtx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
copyCtx.on('agent/request', async (_agent, _turn, _step, requestToCopy) => ({ ...requestToCopy }))
const copiedRequest = await copyCtx.waterfall('agent/request', {
session: { header: { cwd: home } },
} as never, 1, 1, { model: 'm', messages: [], system: 'base' }, () => Promise.resolve({ model: 'm', messages: [], system: 'base' }))
expect((copiedRequest.system ?? '').match(/## Skills/g)).toHaveLength(1)
})
it('cleans up runtime registered skills when the contributing fiber is disposed', async () => {
const ctx = new Context()
const home = await tempDir('skill-runtime')
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.skills.register({
name: 'runtime-skill',
description: 'runtime',
content: 'Runtime body.',
directory: 'memory://runtime',
source: 'runtime',
})
}, { inject: ['skills'] }))
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['runtime-skill'])
await fiber.dispose()
expect(await ctx.skills.list()).toEqual([])
})
it('removes runtime registered skills when the returned disposer is called', async () => {
const home = await tempDir('skill-runtime-disposer')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const dispose = ctx.skills.register({
name: 'manual-dispose',
description: 'manual',
content: 'Manual body.',
directory: 'memory://manual',
source: 'runtime',
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['manual-dispose'])
dispose()
expect(await ctx.skills.list()).toEqual([])
})
it('rejects invalid runtime skill registrations', async () => {
const home = await tempDir('skill-runtime-invalid')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect(() => ctx.skills.register({
name: 'Bad_Name',
description: 'bad',
content: 'bad',
directory: 'memory://bad',
source: 'runtime',
})).toThrow('invalid skill name')
expect(() => ctx.skills.register({
name: 'empty-description',
description: '',
content: 'bad',
directory: 'memory://bad',
source: 'runtime',
})).toThrow('requires a description')
})
})

View File

@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../llm/llm" },
{ "path": "../agent" }
]
}