fix: simplify skill config wiring

This commit is contained in:
Yichen Jiang
2026-07-05 21:05:43 +08:00
parent 77ab82a94c
commit c7a833a893
11 changed files with 37 additions and 68 deletions

View File

@@ -72,27 +72,14 @@ export interface Config extends AgentLoopConfig {
skills?: SkillConfig
}
/** 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),
})
/** The skill config schema exported for app packages that forward `skills`. */
export const SkillConfigSchema: Schema<SkillConfig> = SkillService.Config
/** 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>
/** Bundle schema: reuse agent-loop's agent shape and add skill config. */
export const Config: Schema<Config> = z.intersect([
AgentLoop.Config,
z.object({ skills: SkillConfigSchema }),
])
/**
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;

View File

@@ -37,11 +37,13 @@ Default roots are resolved in this conflict priority order:
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.
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and installs system skills through `ctx.fs.writeText`. Without a filesystem service, the package falls back to Node filesystem I/O so the service can still run in minimal test contexts. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request.
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.
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.
## Prompt Integration

View File

@@ -34,7 +34,6 @@
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -23,6 +23,7 @@ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const DEFAULT_PROMPT_FIELD_LENGTH = 500
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
/** Return whether a string is a valid kebab-case skill name. */
export function isSkillName(name: string): boolean {
return SKILL_NAME.test(name)
}
@@ -365,13 +366,14 @@ async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<Skil
}
async function listSkillRootEntriesFromFileSystem(root: SkillRoot, fs: FileSystem): Promise<SkillRootEntry[]> {
try {
const target = await fs.resolve(root.path)
const entries = await fs.listDir(target)
return entries.map(entryFromFs)
} catch {
return []
}
// 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 {
@@ -476,8 +478,13 @@ async function readSkillText(ctx: Context, path: string): Promise<string | undef
}
async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string): Promise<string | undefined> {
const target = await fs.resolve(path)
const info = await fs.stat(target)
// 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)
@@ -553,7 +560,7 @@ async function findProjectRoot(cwd: string): Promise<string> {
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 }
return { ...skill }
}
function toSummary(skill: SkillDefinition): SkillSummary {

View File

@@ -23,8 +23,10 @@ async function writeFlatSkill(root: string, name: string, description: string, b
class TestFileSystem extends FileSystem {
listDirCalls = 0
failResolvePaths = new Set<string>()
override async resolve(path: string): Promise<FsTarget> {
if (this.failResolvePaths.has(path)) throw new Error('resolve failed')
return { targetKey: path as never, displayPath: path }
}
@@ -426,6 +428,7 @@ describe('SkillService', () => {
const home = await tempDir('skill-read-fs')
const root = join(home, '.dsh/skills')
await writeFlatSkill(root, 'text-skill', 'Text skill', 'Text body.')
await writeFlatSkill(root, 'resolve-fail', 'Resolve fail', 'Resolve 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([
@@ -437,6 +440,7 @@ describe('SkillService', () => {
const ctx = new Context()
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
fs.failResolvePaths.add(join(root, 'resolve-fail.md'))
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

@@ -38,15 +38,6 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
export const name = 'acp-agent'
const SkillConfigSchema: z<agentCore.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),
})
/**
* App config: the swappable per-deployment values. `model`/`systemPrompt`
* configure the agent template the ACP bridge creates each session's agent from
@@ -68,7 +59,7 @@ export const Config: z<Config> = z.object({
model: z.string().required(),
systemPrompt: z.string().required(),
persistenceRoot: z.string().default('./.sessions'),
skills: SkillConfigSchema,
skills: agentCore.SkillConfigSchema,
})
/**

View File

@@ -49,15 +49,6 @@ import * as uiStdio from './stdio-chat.ts'
export const name = 'stdio-agent'
const SkillConfigSchema: z<agentCore.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),
})
/**
* App config: the swappable per-demo values, each routed to where the app wires
* it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main`
@@ -90,7 +81,7 @@ export const Config: z<Config> = z.object({
systemPrompt: z.string().required(),
persistenceRoot: z.string().default('./.sessions'),
welcome: z.string().default('ready.'),
skills: SkillConfigSchema,
skills: agentCore.SkillConfigSchema,
resumeSessionId: z.string(),
})