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 files changed
+37 -68

No files matched your search

+1 -1
View File
@@ -176,7 +176,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefiniti
async renderModelListing(options: SkillLookupOptions = {}): Promise<string>
```
Source: [`packages/core/skill/src/index.ts:132`](../../packages/core/skill/src/index.ts)
Source: [`packages/core/skill/src/index.ts:133`](../../packages/core/skill/src/index.ts)
## `ctx.subagents` — `SubagentService`
@@ -16,7 +16,9 @@ Add `@deepseek-ai/dsh-skill` as the discovery service (`ctx.skills`) and `@deeps
Discovery scans cwd-sensitive project roots, runtime registrations, user roots, extra roots, and system roots in first-wins priority order: project `.dsh`, project `.agents`, runtime, user `.dsh`, user `.agents`, extra roots, then `~/.dsh/skills/.system`. The user `.dsh/skills` scan skips `.system` so built-ins are not discovered twice. Same-name lower-priority skills are ignored with a warning, which lets project and user skills override built-ins deliberately.
Each skill is either `<name>/SKILL.md` or `<name>.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names are kebab-case. YAML frontmatter is parsed with the `yaml` package instead of a hand-written parser because the format already exposes an open `metadata` object and should behave like ordinary skill files rather than a bespoke key/value subset.
Each skill is either `<name>/SKILL.md` or `<name>.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names are kebab-case. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset.
Skill filesystem I/O goes through `ctx.fs` when a filesystem service is loaded: root discovery uses `listDir`, skill reads use `readText`, and system-skill installation uses `writeText`. The Node filesystem remains a fallback for minimal contexts that mount `dsh-skill` without the fs seam. Missing roots and unreadable or malformed skill files degrade to warn-and-skip so one bad local file does not make every agent request fail.
The service injects a request-time `## Skills` fragment through the existing `agent/request` waterfall. It appends to `GenerateOptions.system` instead of changing `systemPrompt.assemble()`, because the available project skills depend on the calling agent's cwd. The fragment contains only stable routing metadata and is sorted by skill name after first-wins collection, so equivalent workspaces produce deterministic prompt text and better prefix-cache reuse. Full skill bodies are never included in the listing.
+7 -20
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;
+3 -1
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
-1
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"
}
+17 -10
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 {
+4
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'])
+1 -10
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,
})
/**
+1 -10
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(),
})
-3
View File
@@ -287,9 +287,6 @@ importers:
'@deepseek-ai/dsh-fs':
specifier: workspace:^
version: link:../../fs/fs
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../../fs/fs-local
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
-11
View File
@@ -1,5 +1,4 @@
import tsconfigPaths from 'vite-tsconfig-paths'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
export default defineConfig({
@@ -19,16 +18,6 @@ export default defineConfig({
// upstream copies (vendor/README.md). The plugin's `projects` option
// instead applies the one root map to every importer.
plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })],
resolve: {
// The root tsconfig maps `schemastery` to its vendored TS source, whose
// upstream module shape is `export = Schema`. Vite/Vitest does not synthesize
// a default export for that source file consistently, while the package's
// built ESM artifact does. Tests exercise harness source but can use the
// vendored dependency's built artifact for this CJS-interop boundary.
alias: {
schemastery: fileURLToPath(new URL('./vendor/schemastery/lib/index.mjs', import.meta.url)),
},
},
test: {
include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'],
coverage: {