fix: simplify skill config wiring
This commit is contained in:
11 files changed
+37
-68
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
Generated
-3
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user