fix: harden skill discovery

This commit is contained in:
Yichen Jiang
2026-07-05 18:33:27 +08:00
parent f626e569a4
commit dca2cc257d
29 changed files with 555 additions and 61 deletions

View File

@@ -47,6 +47,7 @@
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.18.0"
}
}

View File

@@ -44,11 +44,13 @@
import type { Context } from 'cordis'
import Timer from '@cordisjs/plugin-timer'
import z from 'schemastery'
import type Schema from 'schemastery'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SkillService from '@deepseek-ai/dsh-skill'
import SkillService, { type Config as SkillConfig } from '@deepseek-ai/dsh-skill'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import * as invariants from '@deepseek-ai/dsh-invariants'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
@@ -58,16 +60,39 @@ import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agen
export const name = 'agent-core'
/**
* Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]`
* an app that pre-creates no agents (the ACP bridge creates them on demand at
* `session/new`) simply omits it; an app that needs a pre-created `main` (the
* stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and
* the forwarded shape can never drift.
* Bundle config: the agent-loop `agents` list plus skill discovery config.
* Default `agents: []` means an app that pre-creates no agents (the ACP bridge
* creates them on demand at `session/new`) can omit it; an app that needs a
* pre-created `main` (the stdio chat) supplies one. `skills` is forwarded to
* {@link @deepseek-ai/dsh-skill}, so leaf cordis.yml files can change DSH/user
* skill roots and caps without code changes.
*/
export type Config = AgentLoopConfig
export interface Config extends AgentLoopConfig {
/** Skill discovery roots, system-skill installation, and prompt/cache bounds. */
skills?: SkillConfig
}
/** Forward the loop's own schema so validation + defaulting stay identical. */
export const Config = AgentLoop.Config
/** Local schema for the forwarded skill config. Keep this in sync with `SkillService.Config`. */
export const SkillConfigSchema: Schema<SkillConfig> = z.object({
dshHome: z.string(),
agentsHome: z.string(),
extraRoots: z.array(z.string()).default([]),
installSystemSkills: z.boolean().default(true),
promptFieldMaxLength: z.number().default(500),
collectCacheMaxEntries: z.number().default(128),
})
/** Bundle schema: keep the loop agent shape aligned and expose skill config. */
export const Config: Schema<Config> = z.object({
agents: z.array(z.object({
id: z.string().required(),
model: z.string(),
systemPrompt: z.string(),
cwd: z.string(),
resumeSessionId: z.string(),
})).default([]),
skills: SkillConfigSchema,
}) as unknown as Schema<Config>
/**
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
@@ -83,10 +108,12 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(SessionStore)
ctx.plugin(SystemPrompt)
ctx.plugin(ToolRegistry)
ctx.plugin(SkillService)
ctx.plugin(SkillService, config.skills ?? {})
ctx.plugin(AgentRegistry)
ctx.plugin(invariants)
ctx.plugin(toolBash)
ctx.plugin(toolSkill)
ctx.plugin(AgentLoop, { agents: config.agents })
}
export type { SkillConfig }

View File

@@ -19,7 +19,9 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
*/
async function mount(config?: agentCore.Config): Promise<Context> {
const oldDshHome = process.env.DSH_HOME
const oldAgentsHome = process.env.DSH_AGENTS_HOME
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-'))
const ctx = new Context()
try {
await ctx.plugin(agentCore, config)
@@ -33,6 +35,11 @@ async function mount(config?: agentCore.Config): Promise<Context> {
} else {
process.env.DSH_HOME = oldDshHome
}
if (oldAgentsHome === undefined) {
delete process.env.DSH_AGENTS_HOME
} else {
process.env.DSH_AGENTS_HOME = oldAgentsHome
}
}
}
@@ -78,6 +85,21 @@ describe('dsh-agent-core bundle', () => {
await ctx.fiber.dispose()
})
it('forwards skill config to the skill service', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-home-'))
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-agents-'))
const ctx = await mount({
agents: [],
skills: {
dshHome: join(home, '.dsh'),
agentsHome: join(agentsHome, '.agents'),
installSystemSkills: false,
},
})
expect(await ctx.skills.list()).toEqual([])
await ctx.fiber.dispose()
})
it('re-exports the loop config schema as its own', () => {
expect(agentCore.Config).toBeDefined()
expect(agentCore.name).toBe('agent-core')

View File

@@ -14,6 +14,9 @@
{
"path": "../../../vendor/timer"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},

View File

@@ -10,6 +10,17 @@ Agent skill discovery and model-facing skill guidance.
- `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.
### Config
| Field | Default | Meaning |
|---|---|---|
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; system skills live under `skills/.system`. |
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
| `extraRoots` | `[]` | Additional skill roots scanned after user roots and before system skills. |
| `installSystemSkills` | `true` | Whether startup materializes bundled system skills under `dshHome`. |
| `promptFieldMaxLength` | `500` | Maximum rendered `description` / `whenToUse` length in the prompt listing. |
| `collectCacheMaxEntries` | `128` | Maximum cwd/root discovery promises kept in memory. |
### Discovery
Default roots are resolved in this conflict priority order:

View File

@@ -28,6 +28,7 @@
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0",
"yaml": "^2.4.2"
},
"devDependencies": {

View File

@@ -8,18 +8,20 @@
* @module @deepseek-ai/dsh-skill
*/
import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { homedir } from 'node:os'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type Schema from 'schemastery'
import { parse as parseYaml } from 'yaml'
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
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
const MAX_COLLECT_CACHE_ENTRIES = 128
const DEFAULT_PROMPT_FIELD_LENGTH = 500
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
export function isSkillName(name: string): boolean {
return SKILL_NAME.test(name)
@@ -55,9 +57,7 @@ export interface SkillDefinition extends SkillSummary {
}
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & {
disableModelInvocation?: boolean
}
export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & { disableModelInvocation?: boolean }
/** Workspace selector used for cwd-sensitive project-root discovery. */
export interface SkillLookupOptions {
@@ -68,12 +68,16 @@ export interface SkillLookupOptions {
export interface Config {
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Shared agent config root. Defaults to `~/.agents`. */
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.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
/** Maximum rendered description/whenToUse length in the prompt listing. */
promptFieldMaxLength?: number
/** Maximum number of cwd/root discovery promises kept in the in-memory cache. */
collectCacheMaxEntries?: number
}
declare module 'cordis' {
@@ -126,10 +130,21 @@ const SYSTEM_SKILLS: SkillDefinition[] = [
* stable `## Skills` listing into each agent request.
*/
export class SkillService extends Service {
static Config: Schema<Config> = z.object({
dshHome: z.string(),
agentsHome: z.string(),
extraRoots: z.array(z.string()).default([]),
installSystemSkills: z.boolean().default(true),
promptFieldMaxLength: z.number().default(DEFAULT_PROMPT_FIELD_LENGTH),
collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
})
private readonly dshHome: string
private readonly agentsHome: string
private readonly extraRoots: string[]
private readonly installSystemSkills: boolean
private readonly promptFieldMaxLength: number
private readonly collectCacheMaxEntries: number
private readonly runtime = new Map<string, SkillDefinition>()
private readonly collectCache = new Map<string, Promise<SkillDefinition[]>>()
private runtimeRevision = 0
@@ -138,9 +153,13 @@ export class SkillService extends Service {
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.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
this.extraRoots = (config.extraRoots ?? []).map(root => resolve(root))
this.installSystemSkills = config.installSystemSkills ?? true
this.promptFieldMaxLength = config.promptFieldMaxLength ?? DEFAULT_PROMPT_FIELD_LENGTH
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
assertPositiveInteger('promptFieldMaxLength', this.promptFieldMaxLength)
assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
if (this.installSystemSkills) {
const systemRoot = join(this.dshHome, 'skills/.system')
this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => {
@@ -208,8 +227,8 @@ export class SkillService extends Service {
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)}`] : [],
`description: ${promptLine(skill.description, this.promptFieldMaxLength)}`,
...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse, this.promptFieldMaxLength)}`] : [],
'</skill>',
]
return lines.join('\n')
@@ -231,12 +250,16 @@ export class SkillService extends Service {
if (cached !== undefined) return cached
const collected = this.collectFresh(roots)
this.collectCache.set(key, collected)
if (this.collectCache.size > MAX_COLLECT_CACHE_ENTRIES) {
const cachedPromise = collected.catch((error: unknown) => {
this.collectCache.delete(key)
throw error
})
this.collectCache.set(key, cachedPromise)
if (this.collectCache.size > this.collectCacheMaxEntries) {
const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
this.collectCache.delete(oldest.value)
}
return collected
return cachedPromise
}
private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise<SkillDefinition[]> {
@@ -326,9 +349,10 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinit
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()
const kind = await entryKind(fullPath, entry, ctx)
const parsed = kind === 'directory'
? await parseSkillFile(join(fullPath, 'SKILL.md'), fullPath, root.source, ctx)
: entry.isFile() && entry.name.endsWith('.md')
: kind === 'file' && entry.name.endsWith('.md')
? await parseSkillFile(fullPath, root.path, root.source, ctx)
: undefined
if (parsed) skills.push(parsed)
@@ -341,7 +365,13 @@ async function parseSkillFile(path: string, directory: string, source: SkillSour
if (raw === undefined) {
return undefined
}
const parsed = parseFrontmatter(raw)
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
@@ -426,15 +456,48 @@ function fsReadErrorMessage(target: FsTarget, error: unknown): string {
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
}
async function entryKind(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 {
if (!raw.startsWith('---\n')) return undefined
const end = raw.indexOf('\n---', 4)
if (end < 0) return undefined
const yaml = raw.slice(4, end)
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
const body = raw.slice(end + 4)
return { data: parsed as Record<string, unknown>, body: body.startsWith('\n') ? body.slice(1) : body }
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): Promise<string> {
@@ -474,14 +537,20 @@ function compareSummary(left: SkillSummary, right: SkillSummary): number {
return left.name.localeCompare(right.name)
}
function promptLine(value: string): string {
function promptLine(value: string, maxLength: number): string {
const normalized = value.replaceAll(/\s+/g, ' ').trim()
const truncated = normalized.length <= MAX_PROMPT_FIELD_LENGTH
const truncated = normalized.length <= maxLength
? normalized
: `${normalized.slice(0, MAX_PROMPT_FIELD_LENGTH - 3)}...`
: `${normalized.slice(0, maxLength - 3)}...`
return escapeText(truncated)
}
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`skill: ${name} must be a positive integer`)
}
}
function stringField(data: Record<string, unknown>, key: string): string | undefined {
const value = data[key]
return typeof value === 'string' && value.length > 0 ? value : undefined

View File

@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { mkdir, readFile, 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 LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { FileSystem, FsVersion, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
@@ -21,6 +21,50 @@ async function writeFlatSkill(root: string, name: string, description: string, b
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
}
class TestFileSystem extends FileSystem {
override async resolve(path: string): Promise<FsTarget> {
return { targetKey: path as never, displayPath: path }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
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(): Promise<never> {
throw new Error('not needed in skill tests')
}
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')
}
}
describe('SkillService', () => {
it('discovers project, user, agents, and system skill roots in priority order', async () => {
const home = await tempDir('skill-home')
@@ -113,6 +157,7 @@ describe('SkillService', () => {
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/plain-markdown.md'), '# Notes\nNot a skill.')
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---')
@@ -129,22 +174,141 @@ describe('SkillService', () => {
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
})
it('keeps skill body text that begins immediately after the closing frontmatter delimiter', async () => {
const home = await tempDir('skill-frontmatter-body')
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, 'tight-body.md'), [
await writeFile(join(root, 'crlf-skill.md'), [
'---',
'name: tight-body',
'description: Tight body',
'---First line must survive.',
'Second line.',
'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 = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.get('tight-body'))?.content).toBe('First line must survive.\nSecond line.')
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 poisoning discovery cache', 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 = 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(['good-skill'])
await writeFile(join(root, 'bad-yaml.md'), '---\nname: fixed-skill\ndescription: Fixed skill\n---\n\nFixed body.\n')
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
const dispose = ctx.skills.register({
name: 'runtime-skill',
description: 'Runtime skill',
content: 'Runtime body.',
directory: 'memory://runtime',
source: 'runtime',
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['fixed-skill', 'good-skill', 'runtime-skill'])
dispose()
})
it('does not cache a rejected discovery promise', async () => {
const home = await tempDir('skill-rejected-cache')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const internals = ctx.skills as unknown as {
collectFresh(roots: unknown): Promise<unknown[]>
}
const original = internals.collectFresh.bind(ctx.skills)
let fail = true
internals.collectFresh = async (roots: unknown) => {
if (fail) throw new Error('transient discovery failure')
return await original(roots)
}
await expect(ctx.skills.list()).rejects.toThrow('transient discovery failure')
fail = false
await writeSkill(join(home, '.dsh/skills'), 'late-good', 'Late good')
await expect(ctx.skills.list()).resolves.toMatchObject([{ name: 'late-good' }])
})
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 = 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(['linked-dir', 'linked-flat'])
})
it('honors prompt and cache bounds from config', async () => {
const home = await tempDir('skill-config-bounds')
const firstProject = await tempDir('skill-config-first')
const secondProject = await tempDir('skill-config-second')
await mkdir(join(firstProject, '.git'), { recursive: true })
await mkdir(join(secondProject, '.git'), { recursive: true })
await writeSkill(join(firstProject, '.dsh/skills'), 'first-skill', 'abcdefghij')
await writeSkill(join(secondProject, '.dsh/skills'), 'second-skill', 'Second')
const ctx = new Context()
await ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
installSystemSkills: false,
promptFieldMaxLength: 6,
collectCacheMaxEntries: 1,
})
expect(await ctx.skills.renderModelListing({ cwd: firstProject })).toContain('description: abc...')
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill'])
await writeSkill(join(firstProject, '.dsh/skills'), 'late-first', 'Late first')
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill'])
await ctx.skills.list({ cwd: secondProject })
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill', 'late-first'])
})
it('rejects invalid positive-integer config caps', async () => {
const home = await tempDir('skill-invalid-config')
const ctx = new Context()
await expect(ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
installSystemSkills: false,
promptFieldMaxLength: 0,
})).rejects.toThrow('promptFieldMaxLength')
await expect(ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
installSystemSkills: false,
collectCacheMaxEntries: 1.5,
})).rejects.toThrow('collectCacheMaxEntries')
})
it('renders no model listing when no model-invocable skills exist', async () => {
@@ -178,6 +342,16 @@ describe('SkillService', () => {
}
})
it('keeps constructor defaults when schema preprocessing is not involved', async () => {
const home = await tempDir('skill-constructor-defaults')
const service = new SkillService(new Context(), {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
})
expect((await service.list()).map(skill => skill.name)).toEqual(['dsh-plugin-creator', 'dsh-skill-creator'])
})
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')
@@ -202,7 +376,7 @@ describe('SkillService', () => {
await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Existing system skill\n---\n\nExisting body.\n')
const ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: home })
await ctx.plugin(TestFileSystem)
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
@@ -237,7 +411,7 @@ describe('SkillService', () => {
]))
const ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: home })
await ctx.plugin(TestFileSystem)
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['text-skill'])

View File

@@ -8,6 +8,7 @@
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../fs/fs" },
{ "path": "../../llm/llm" },
{ "path": "../agent" }