diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 066bc09b6a..2753a50158 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -176,7 +176,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise ``` -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` diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.md b/docs/rfc/implemented/feature/2026-07-05-skill-system.md index eea4ac91ae..6fda27b957 100644 --- a/docs/rfc/implemented/feature/2026-07-05-skill-system.md +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.md @@ -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 `/SKILL.md` or `.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 `/SKILL.md` or `.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. diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index 0f8e005b56..ff227cb6bc 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -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 = 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 = SkillService.Config -/** Bundle schema: keep the loop agent shape aligned and expose skill config. */ -export const Config: Schema = 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 +/** Bundle schema: reuse agent-loop's agent shape and add skill config. */ +export const Config: Schema = z.intersect([ + AgentLoop.Config, + z.object({ skills: SkillConfigSchema }), +]) /** * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber; diff --git a/packages/core/skill/README.md b/packages/core/skill/README.md index 39deef415b..a91c3f116d 100644 --- a/packages/core/skill/README.md +++ b/packages/core/skill/README.md @@ -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 (`/SKILL.md`) or flat Markdown files (`.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 (`/SKILL.md`) or flat Markdown files (`.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 diff --git a/packages/core/skill/package.json b/packages/core/skill/package.json index 9bca3c1a24..efa1646e14 100644 --- a/packages/core/skill/package.json +++ b/packages/core/skill/package.json @@ -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" } diff --git a/packages/core/skill/src/index.ts b/packages/core/skill/src/index.ts index d2a228dc1c..503057b8bd 100644 --- a/packages/core/skill/src/index.ts +++ b/packages/core/skill/src/index.ts @@ -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 { - 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 { + 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 { - 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 { 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 { diff --git a/packages/core/skill/tests/skill.spec.ts b/packages/core/skill/tests/skill.spec.ts index be16483a1e..ef5d314190 100644 --- a/packages/core/skill/tests/skill.spec.ts +++ b/packages/core/skill/tests/skill.spec.ts @@ -23,8 +23,10 @@ async function writeFlatSkill(root: string, name: string, description: string, b class TestFileSystem extends FileSystem { listDirCalls = 0 + failResolvePaths = new Set() override async resolve(path: string): Promise { + 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']) diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index 26ca874ff8..c3c869cd9d 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -38,15 +38,6 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' export const name = 'acp-agent' -const SkillConfigSchema: z = 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 = z.object({ model: z.string().required(), systemPrompt: z.string().required(), persistenceRoot: z.string().default('./.sessions'), - skills: SkillConfigSchema, + skills: agentCore.SkillConfigSchema, }) /** diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 2a370f5cb3..1753039829 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -49,15 +49,6 @@ import * as uiStdio from './stdio-chat.ts' export const name = 'stdio-agent' -const SkillConfigSchema: z = 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 = z.object({ systemPrompt: z.string().required(), persistenceRoot: z.string().default('./.sessions'), welcome: z.string().default('ready.'), - skills: SkillConfigSchema, + skills: agentCore.SkillConfigSchema, resumeSessionId: z.string(), }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e9291924a..7843b3920a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/vitest.config.ts b/vitest.config.ts index 7562dd628b..6afd87dcb0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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: {