fix: simplify skill config wiring
This commit is contained in:
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user