fix: harden skill discovery

This commit is contained in:
Yichen Jiang
2026-07-05 18:33:27 +08:00
parent f626e569a4
commit dca2cc257d
29 changed files with 555 additions and 61 deletions
+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:128`](../../packages/core/skill/src/index.ts)
Source: [`packages/core/skill/src/index.ts:132`](../../packages/core/skill/src/index.ts)
## `ctx.subagents` — `SubagentService`
+2
View File
@@ -79,6 +79,8 @@ interface Config {
agentsHome?: string
extraRoots?: string[]
installSystemSkills?: boolean
promptFieldMaxLength?: number
collectCacheMaxEntries?: number
}
```
+17 -2
View File
@@ -63,7 +63,16 @@ function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawn
const child = spawn(
process.execPath,
['--import', tsxLoader, binScript, configPath],
{ cwd, env: { ...env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] },
{
cwd,
env: {
...env,
TSX_TSCONFIG_PATH: repoTsconfig,
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
},
)
const stderr: string[] = []
child.stderr.setEncoding('utf8')
@@ -110,7 +119,13 @@ describe('acp-agent over real stdio (no key required)', () => {
// which this purity test never triggers). So this runs WITHOUT real creds.
const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], {
cwd: workdir,
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig },
env: {
...process.env,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
TSX_TSCONFIG_PATH: repoTsconfig,
DSH_HOME: join(workdir, '.dsh'),
DSH_AGENTS_HOME: join(workdir, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
})
const out: string[] = []
+1
View File
@@ -64,6 +64,7 @@ const SCENARIOS: Scenario[] = [
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
{ name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
{ name: 'todo-plan', hasModelTurn: true, recorded: true },
{ name: 'skill-load', hasModelTurn: true, recorded: false },
{ name: 'workspace-edit', hasModelTurn: true, recorded: true },
{ name: 'fs-read', hasModelTurn: true, recorded: true },
{ name: 'fs-write', hasModelTurn: true, recorded: true },
@@ -155,6 +155,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
DSH_SNAPSHOT: opts.mode,
DSH_SNAPSHOT_FILE: opts.fixtureFile,
DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {},
...opts.childFiles !== undefined && opts.childFiles.length > 0
? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) }
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Load the dsh-skill-creator skill with the skill tool, then reply DONE." }
]
}
@@ -0,0 +1,28 @@
[
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "reasoning" },
{ "type": "reasoning-delta", "index": 0, "text": "Load the requested skill." },
{ "type": "block-start", "index": 1, "blockType": "tool-call" },
{ "type": "tool-call-delta", "index": 1, "id": "call_skill_load", "name": "skill", "argumentsDelta": "{\"name\":\"dsh-skill-creator\"}" },
{ "type": "block-end", "index": 0, "block": { "type": "reasoning", "text": "Load the requested skill." } },
{ "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_skill_load", "name": "skill", "arguments": "{\"name\":\"dsh-skill-creator\"}" } },
{ "type": "usage", "usage": { "inputTokens": 100, "outputTokens": 20, "cacheReadTokens": 0, "reasoningTokens": 5 } },
{ "type": "finish", "reason": { "kind": "tool-calls" } }
]
},
{
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "reasoning" },
{ "type": "reasoning-delta", "index": 0, "text": "The skill is loaded." },
{ "type": "block-start", "index": 1, "blockType": "text" },
{ "type": "text-delta", "index": 1, "text": "DONE" },
{ "type": "block-end", "index": 0, "block": { "type": "reasoning", "text": "The skill is loaded." } },
{ "type": "block-end", "index": 1, "block": { "type": "text", "text": "DONE" } },
{ "type": "usage", "usage": { "inputTokens": 180, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 4 } },
{ "type": "finish", "reason": { "kind": "stop" } }
]
}
]
@@ -0,0 +1,28 @@
{"type":"session","version":0,"id":"5ca6619f-135d-4d55-814b-35ac6524a1b2","createdAt":1783246260513,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-nJAoRn"}
{"type":"turn/start","seq":0,"time":1783246260515,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783246260516,"data":{"content":[{"type":"text","text":"Load the dsh-skill-creator skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783246260516,"data":{"turn":1,"step":1}}
{"type":"assistant/chunk","seq":3,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":4,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}}
{"type":"assistant/chunk","seq":5,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":6,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"dsh-skill-creator\"}"}}}
{"type":"assistant/chunk","seq":7,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}}}
{"type":"assistant/chunk","seq":8,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"dsh-skill-creator\"}"}}}}
{"type":"assistant/chunk","seq":9,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}}}
{"type":"assistant/chunk","seq":10,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":11,"time":1783246260534,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"dsh-skill-creator\"}"}],"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[3,4,5,6,7,8,9,10],"surfaceOp":"append"}
{"type":"tool/call","seq":12,"time":1783246260534,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"dsh-skill-creator\"}"}}
{"type":"tool/result","seq":13,"time":1783246260535,"data":{"turn":1,"step":1,"callId":"call_skill_load","content":[{"type":"text","text":"<skill_content name=\"dsh-skill-creator\">\n# Skill: dsh-skill-creator\n\nUse this skill to write focused DeepSeek Harness skills.\n\nA skill is a directory `<name>/SKILL.md` or a flat `<name>.md` file with YAML frontmatter.\nFrontmatter must include kebab-case `name` and a concise `description` that tells the model when to load it.\nUse optional `whenToUse` for extra routing signal and `disableModelInvocation: true` for user-only skills.\nKeep the body procedural, evidence-oriented, and scoped to the workflow the skill owns.\n\nBase directory for this skill: /var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-nJAoRn/.dsh/skills/.system/dsh-skill-creator\nResolve relative files mentioned by this skill against the base directory before using them.\n</skill_content>"}],"isError":false},"sourceEventSeqs":[12],"surfaceOp":"append"}
{"type":"step/end","seq":14,"time":1783246260535,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":15,"time":1783246260535,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":16,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":17,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The skill is loaded."}}}
{"type":"assistant/chunk","seq":18,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":19,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"DONE"}}}
{"type":"assistant/chunk","seq":20,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}}}
{"type":"assistant/chunk","seq":21,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":22,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}}}
{"type":"assistant/chunk","seq":23,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":24,"time":1783246260536,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}},"sourceEventSeqs":[16,17,18,19,20,21,22,23],"surfaceOp":"append"}
{"type":"step/end","seq":25,"time":1783246260536,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":26,"time":1783246260536,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,8 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill dsh-skill-creator","kind":"read","status":"in_progress","rawInput":"dsh-skill-creator"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<skill_content name=\"dsh-skill-creator\">\n# Skill: dsh-skill-creator\n\nUse this skill to write focused DeepSeek Harness skills.\n\nA skill is a directory `<name>/SKILL.md` or a flat `<name>.md` file with YAML frontmatter.\nFrontmatter must include kebab-case `name` and a concise `description` that tells the model when to load it.\nUse optional `whenToUse` for extra routing signal and `disableModelInvocation: true` for user-only skills.\nKeep the body procedural, evidence-oriented, and scoped to the workflow the skill owns.\n\nBase directory for this skill: {{cwd}}/.dsh/skills/.system/dsh-skill-creator\nResolve relative files mentioned by this skill against the base directory before using them.\n</skill_content>"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The skill is loaded."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -62,6 +62,8 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> {
// A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
// No prompt is sent, so the adapter never streams — no network call.
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
},
+10 -1
View File
@@ -63,7 +63,16 @@ async function runEcho(lines: string[]): Promise<{ stdout: string; code: number
// requires it (mirrors the `demo:echo` script). The whole point is to boot
// the example EXACTLY as it really runs, through the bin + Loader.
['--expose-internals', '--import', tsxLoader, binScript, configPath],
{ cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] },
{
cwd,
env: {
...process.env,
TSX_TSCONFIG_PATH: repoTsconfig,
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
},
)
child = proc
let stdout = ''
+2 -1
View File
@@ -47,6 +47,7 @@
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.18.0"
}
}
+37 -10
View File
@@ -44,11 +44,13 @@
import type { Context } from 'cordis'
import Timer from '@cordisjs/plugin-timer'
import z from 'schemastery'
import type Schema from 'schemastery'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SkillService from '@deepseek-ai/dsh-skill'
import SkillService, { type Config as SkillConfig } from '@deepseek-ai/dsh-skill'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import * as invariants from '@deepseek-ai/dsh-invariants'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
@@ -58,16 +60,39 @@ import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agen
export const name = 'agent-core'
/**
* Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]`
* an app that pre-creates no agents (the ACP bridge creates them on demand at
* `session/new`) simply omits it; an app that needs a pre-created `main` (the
* stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and
* the forwarded shape can never drift.
* Bundle config: the agent-loop `agents` list plus skill discovery config.
* Default `agents: []` means an app that pre-creates no agents (the ACP bridge
* creates them on demand at `session/new`) can omit it; an app that needs a
* pre-created `main` (the stdio chat) supplies one. `skills` is forwarded to
* {@link @deepseek-ai/dsh-skill}, so leaf cordis.yml files can change DSH/user
* skill roots and caps without code changes.
*/
export type Config = AgentLoopConfig
export interface Config extends AgentLoopConfig {
/** Skill discovery roots, system-skill installation, and prompt/cache bounds. */
skills?: SkillConfig
}
/** Forward the loop's own schema so validation + defaulting stay identical. */
export const Config = AgentLoop.Config
/** 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),
})
/** 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>
/**
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
@@ -83,10 +108,12 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(SessionStore)
ctx.plugin(SystemPrompt)
ctx.plugin(ToolRegistry)
ctx.plugin(SkillService)
ctx.plugin(SkillService, config.skills ?? {})
ctx.plugin(AgentRegistry)
ctx.plugin(invariants)
ctx.plugin(toolBash)
ctx.plugin(toolSkill)
ctx.plugin(AgentLoop, { agents: config.agents })
}
export type { SkillConfig }
@@ -19,7 +19,9 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
*/
async function mount(config?: agentCore.Config): Promise<Context> {
const oldDshHome = process.env.DSH_HOME
const oldAgentsHome = process.env.DSH_AGENTS_HOME
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-'))
const ctx = new Context()
try {
await ctx.plugin(agentCore, config)
@@ -33,6 +35,11 @@ async function mount(config?: agentCore.Config): Promise<Context> {
} else {
process.env.DSH_HOME = oldDshHome
}
if (oldAgentsHome === undefined) {
delete process.env.DSH_AGENTS_HOME
} else {
process.env.DSH_AGENTS_HOME = oldAgentsHome
}
}
}
@@ -78,6 +85,21 @@ describe('dsh-agent-core bundle', () => {
await ctx.fiber.dispose()
})
it('forwards skill config to the skill service', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-home-'))
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-agents-'))
const ctx = await mount({
agents: [],
skills: {
dshHome: join(home, '.dsh'),
agentsHome: join(agentsHome, '.agents'),
installSystemSkills: false,
},
})
expect(await ctx.skills.list()).toEqual([])
await ctx.fiber.dispose()
})
it('re-exports the loop config schema as its own', () => {
expect(agentCore.Config).toBeDefined()
expect(agentCore.name).toBe('agent-core')
+3
View File
@@ -14,6 +14,9 @@
{
"path": "../../../vendor/timer"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
+11
View File
@@ -10,6 +10,17 @@ Agent skill discovery and model-facing skill guidance.
- `ctx.skills.get(name, { cwd? })` Returns the full skill, including disabled-for-model skills.
- `ctx.skills.register(skill): () => void` Registers a runtime skill, disposed with the calling fiber.
### Config
| Field | Default | Meaning |
|---|---|---|
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; system skills live under `skills/.system`. |
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
| `extraRoots` | `[]` | Additional skill roots scanned after user roots and before system skills. |
| `installSystemSkills` | `true` | Whether startup materializes bundled system skills under `dshHome`. |
| `promptFieldMaxLength` | `500` | Maximum rendered `description` / `whenToUse` length in the prompt listing. |
| `collectCacheMaxEntries` | `128` | Maximum cwd/root discovery promises kept in memory. |
### Discovery
Default roots are resolved in this conflict priority order:
+1
View File
@@ -28,6 +28,7 @@
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0",
"yaml": "^2.4.2"
},
"devDependencies": {
+94 -25
View File
@@ -8,18 +8,20 @@
* @module @deepseek-ai/dsh-skill
*/
import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { homedir } from 'node:os'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type Schema from 'schemastery'
import { parse as parseYaml } from 'yaml'
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-agent'
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const MAX_PROMPT_FIELD_LENGTH = 500
const MAX_COLLECT_CACHE_ENTRIES = 128
const DEFAULT_PROMPT_FIELD_LENGTH = 500
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
export function isSkillName(name: string): boolean {
return SKILL_NAME.test(name)
@@ -55,9 +57,7 @@ export interface SkillDefinition extends SkillSummary {
}
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & {
disableModelInvocation?: boolean
}
export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & { disableModelInvocation?: boolean }
/** Workspace selector used for cwd-sensitive project-root discovery. */
export interface SkillLookupOptions {
@@ -68,12 +68,16 @@ export interface SkillLookupOptions {
export interface Config {
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Shared agent config root. Defaults to `~/.agents`. */
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
agentsHome?: string
/** Extra skill roots, scanned after user roots and before system skills. */
extraRoots?: string[]
/** Ensure bundled system skills exist under `<dshHome>/skills/.system`. Defaults true. */
installSystemSkills?: boolean
/** Maximum rendered description/whenToUse length in the prompt listing. */
promptFieldMaxLength?: number
/** Maximum number of cwd/root discovery promises kept in the in-memory cache. */
collectCacheMaxEntries?: number
}
declare module 'cordis' {
@@ -126,10 +130,21 @@ const SYSTEM_SKILLS: SkillDefinition[] = [
* stable `## Skills` listing into each agent request.
*/
export class SkillService extends Service {
static Config: Schema<Config> = z.object({
dshHome: z.string(),
agentsHome: z.string(),
extraRoots: z.array(z.string()).default([]),
installSystemSkills: z.boolean().default(true),
promptFieldMaxLength: z.number().default(DEFAULT_PROMPT_FIELD_LENGTH),
collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
})
private readonly dshHome: string
private readonly agentsHome: string
private readonly extraRoots: string[]
private readonly installSystemSkills: boolean
private readonly promptFieldMaxLength: number
private readonly collectCacheMaxEntries: number
private readonly runtime = new Map<string, SkillDefinition>()
private readonly collectCache = new Map<string, Promise<SkillDefinition[]>>()
private runtimeRevision = 0
@@ -138,9 +153,13 @@ export class SkillService extends Service {
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'skills')
this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
this.agentsHome = resolve(config.agentsHome ?? join(homedir(), '.agents'))
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
this.extraRoots = (config.extraRoots ?? []).map(root => resolve(root))
this.installSystemSkills = config.installSystemSkills ?? true
this.promptFieldMaxLength = config.promptFieldMaxLength ?? DEFAULT_PROMPT_FIELD_LENGTH
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
assertPositiveInteger('promptFieldMaxLength', this.promptFieldMaxLength)
assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
if (this.installSystemSkills) {
const systemRoot = join(this.dshHome, 'skills/.system')
this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => {
@@ -208,8 +227,8 @@ export class SkillService extends Service {
const entries = skills.map((skill) => {
const lines = [
`<skill name="${escapeAttr(skill.name)}" source="${escapeAttr(skill.source)}">`,
`description: ${promptLine(skill.description)}`,
...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse)}`] : [],
`description: ${promptLine(skill.description, this.promptFieldMaxLength)}`,
...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse, this.promptFieldMaxLength)}`] : [],
'</skill>',
]
return lines.join('\n')
@@ -231,12 +250,16 @@ export class SkillService extends Service {
if (cached !== undefined) return cached
const collected = this.collectFresh(roots)
this.collectCache.set(key, collected)
if (this.collectCache.size > MAX_COLLECT_CACHE_ENTRIES) {
const cachedPromise = collected.catch((error: unknown) => {
this.collectCache.delete(key)
throw error
})
this.collectCache.set(key, cachedPromise)
if (this.collectCache.size > this.collectCacheMaxEntries) {
const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
this.collectCache.delete(oldest.value)
}
return collected
return cachedPromise
}
private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise<SkillDefinition[]> {
@@ -326,9 +349,10 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinit
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (root.skipSystem && entry.name === '.system') continue
const fullPath = join(root.path, entry.name)
const parsed = entry.isDirectory()
const kind = await entryKind(fullPath, entry, ctx)
const parsed = kind === 'directory'
? await parseSkillFile(join(fullPath, 'SKILL.md'), fullPath, root.source, ctx)
: entry.isFile() && entry.name.endsWith('.md')
: kind === 'file' && entry.name.endsWith('.md')
? await parseSkillFile(fullPath, root.path, root.source, ctx)
: undefined
if (parsed) skills.push(parsed)
@@ -341,7 +365,13 @@ async function parseSkillFile(path: string, directory: string, source: SkillSour
if (raw === undefined) {
return undefined
}
const parsed = parseFrontmatter(raw)
let parsed
try {
parsed = parseFrontmatter(raw)
} catch (error) {
ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`)
return undefined
}
if (!parsed) {
ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
return undefined
@@ -426,15 +456,48 @@ function fsReadErrorMessage(target: FsTarget, error: unknown): string {
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
}
async function entryKind(fullPath: string, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }, ctx: Context): Promise<'directory' | 'file' | undefined> {
if (entry.isDirectory()) return 'directory'
if (entry.isFile()) return 'file'
/* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */
if (!entry.isSymbolicLink()) return undefined
try {
const info = await stat(fullPath)
if (info.isDirectory()) return 'directory'
if (info.isFile()) return 'file'
return undefined
} catch (error) {
ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)
return undefined
}
}
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
if (!raw.startsWith('---\n')) return undefined
const end = raw.indexOf('\n---', 4)
if (end < 0) return undefined
const yaml = raw.slice(4, end)
const firstLineEnd = raw.indexOf('\n')
if (firstLineEnd < 0) return undefined
const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
if (firstLine !== '---') return undefined
const start = firstLineEnd + 1
const closing = findClosingFrontmatter(raw, start)
if (closing === undefined) return undefined
const yaml = raw.slice(start, closing.start)
const parsed = parseYaml(yaml) as unknown
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
const body = raw.slice(end + 4)
return { data: parsed as Record<string, unknown>, body: body.startsWith('\n') ? body.slice(1) : body }
return { data: parsed as Record<string, unknown>, body: raw.slice(closing.bodyStart) }
}
function findClosingFrontmatter(raw: string, start: number): { start: number; bodyStart: number } | undefined {
let lineStart = start
while (lineStart <= raw.length) {
const nextNewline = raw.indexOf('\n', lineStart)
const lineEnd = nextNewline < 0 ? raw.length : nextNewline
const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '')
if (line === '---') {
return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }
}
if (nextNewline < 0) return undefined
lineStart = nextNewline + 1
}
}
async function findProjectRoot(cwd: string): Promise<string> {
@@ -474,14 +537,20 @@ function compareSummary(left: SkillSummary, right: SkillSummary): number {
return left.name.localeCompare(right.name)
}
function promptLine(value: string): string {
function promptLine(value: string, maxLength: number): string {
const normalized = value.replaceAll(/\s+/g, ' ').trim()
const truncated = normalized.length <= MAX_PROMPT_FIELD_LENGTH
const truncated = normalized.length <= maxLength
? normalized
: `${normalized.slice(0, MAX_PROMPT_FIELD_LENGTH - 3)}...`
: `${normalized.slice(0, maxLength - 3)}...`
return escapeText(truncated)
}
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`skill: ${name} must be a positive integer`)
}
}
function stringField(data: Record<string, unknown>, key: string): string | undefined {
const value = data[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
+187 -13
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import SkillService from '@deepseek-ai/dsh-skill'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { FileSystem, FsVersion, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
@@ -21,6 +21,50 @@ async function writeFlatSkill(root: string, name: string, description: string, b
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
}
class TestFileSystem extends FileSystem {
override async resolve(path: string): Promise<FsTarget> {
return { targetKey: path as never, displayPath: path }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
try {
const fs = await import('node:fs/promises')
const info = await fs.stat(target.displayPath)
return {
version: FsVersion(String(info.mtimeMs)),
type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
size: info.size,
}
} catch {
return undefined
}
}
override async readText(target: FsTarget): Promise<string> {
const text = await readFile(target.displayPath, 'utf8')
if (text.includes('\uFFFD')) throw new Error('not text')
return text
}
override async streamText(_target: FsTarget): Promise<AsyncIterable<string>> {
throw new Error('not needed in skill tests')
}
override async listDir(): Promise<never> {
throw new Error('not needed in skill tests')
}
override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
await mkdir(dirname(target.displayPath), { recursive: true })
await writeFile(target.displayPath, content)
return { operation: 'create', version: FsVersion('test'), before: null, after: content }
}
override async editText(_target: FsTarget, _request: FsEditRequest): Promise<FsEditOutcome> {
throw new Error('not needed in skill tests')
}
}
describe('SkillService', () => {
it('discovers project, user, agents, and system skill roots in priority order', async () => {
const home = await tempDir('skill-home')
@@ -113,6 +157,7 @@ describe('SkillService', () => {
await writeFile(join(home, '.dsh/skills/bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
await writeFile(join(home, '.dsh/skills/missing-description.md'), '---\nname: missing-description\n---\n\nbad')
await writeFile(join(home, '.dsh/skills/no-frontmatter.md'), 'No frontmatter.')
await writeFile(join(home, '.dsh/skills/plain-markdown.md'), '# Notes\nNot a skill.')
await writeFile(join(home, '.dsh/skills/open-frontmatter.md'), '---\nname: open-frontmatter')
await writeFile(join(home, '.dsh/skills/non-object.md'), '---\n[]\n---\n\nbad')
await writeFile(join(home, '.dsh/skills/no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
@@ -129,22 +174,141 @@ describe('SkillService', () => {
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
})
it('keeps skill body text that begins immediately after the closing frontmatter delimiter', async () => {
const home = await tempDir('skill-frontmatter-body')
it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
const home = await tempDir('skill-frontmatter-crlf')
const root = join(home, '.dsh/skills')
await mkdir(root, { recursive: true })
await writeFile(join(root, 'tight-body.md'), [
await writeFile(join(root, 'crlf-skill.md'), [
'---',
'name: tight-body',
'description: Tight body',
'---First line must survive.',
'Second line.',
'name: crlf-skill',
'description: CRLF skill',
'metadata:',
' marker: "----"',
'---',
'',
'CRLF body.',
].join('\r\n'))
await writeFile(join(root, 'block-skill.md'), [
'---',
'name: block-skill',
'description: |',
' Includes a ---- marker that is not a delimiter.',
'---',
'',
'Block body.',
].join('\n'))
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.get('tight-body'))?.content).toBe('First line must survive.\nSecond line.')
expect((await ctx.skills.get('crlf-skill'))?.content).toBe('CRLF body.')
expect((await ctx.skills.get('crlf-skill'))?.metadata).toEqual({ marker: '----' })
expect((await ctx.skills.get('block-skill'))?.description).toBe('Includes a ---- marker that is not a delimiter.\n')
expect((await ctx.skills.get('block-skill'))?.content).toBe('Block body.')
})
it('skips invalid YAML skill files without poisoning discovery cache', async () => {
const home = await tempDir('skill-invalid-yaml')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'good-skill', 'Good skill')
await writeFile(join(root, 'bad-yaml.md'), '---\nname: bad-yaml\ndescription: [unclosed\n---\n\nBad body.\n')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
await writeFile(join(root, 'bad-yaml.md'), '---\nname: fixed-skill\ndescription: Fixed skill\n---\n\nFixed body.\n')
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
const dispose = ctx.skills.register({
name: 'runtime-skill',
description: 'Runtime skill',
content: 'Runtime body.',
directory: 'memory://runtime',
source: 'runtime',
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['fixed-skill', 'good-skill', 'runtime-skill'])
dispose()
})
it('does not cache a rejected discovery promise', async () => {
const home = await tempDir('skill-rejected-cache')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const internals = ctx.skills as unknown as {
collectFresh(roots: unknown): Promise<unknown[]>
}
const original = internals.collectFresh.bind(ctx.skills)
let fail = true
internals.collectFresh = async (roots: unknown) => {
if (fail) throw new Error('transient discovery failure')
return await original(roots)
}
await expect(ctx.skills.list()).rejects.toThrow('transient discovery failure')
fail = false
await writeSkill(join(home, '.dsh/skills'), 'late-good', 'Late good')
await expect(ctx.skills.list()).resolves.toMatchObject([{ name: 'late-good' }])
})
it('discovers symlinked skill directories and flat files', async () => {
const home = await tempDir('skill-symlink-home')
const external = await tempDir('skill-symlink-external')
await writeSkill(external, 'linked-dir', 'Linked directory')
await writeFlatSkill(external, 'linked-flat', 'Linked flat')
await mkdir(join(home, '.dsh/skills'), { recursive: true })
await symlink(join(external, 'linked-dir'), join(home, '.dsh/skills/linked-dir'))
await symlink(join(external, 'linked-flat.md'), join(home, '.dsh/skills/linked-flat.md'))
await symlink(join(external, 'missing'), join(home, '.dsh/skills/broken-link'))
await symlink('/dev/null', join(home, '.dsh/skills/device-link'))
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
})
it('honors prompt and cache bounds from config', async () => {
const home = await tempDir('skill-config-bounds')
const firstProject = await tempDir('skill-config-first')
const secondProject = await tempDir('skill-config-second')
await mkdir(join(firstProject, '.git'), { recursive: true })
await mkdir(join(secondProject, '.git'), { recursive: true })
await writeSkill(join(firstProject, '.dsh/skills'), 'first-skill', 'abcdefghij')
await writeSkill(join(secondProject, '.dsh/skills'), 'second-skill', 'Second')
const ctx = new Context()
await ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
installSystemSkills: false,
promptFieldMaxLength: 6,
collectCacheMaxEntries: 1,
})
expect(await ctx.skills.renderModelListing({ cwd: firstProject })).toContain('description: abc...')
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill'])
await writeSkill(join(firstProject, '.dsh/skills'), 'late-first', 'Late first')
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill'])
await ctx.skills.list({ cwd: secondProject })
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill', 'late-first'])
})
it('rejects invalid positive-integer config caps', async () => {
const home = await tempDir('skill-invalid-config')
const ctx = new Context()
await expect(ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
installSystemSkills: false,
promptFieldMaxLength: 0,
})).rejects.toThrow('promptFieldMaxLength')
await expect(ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
installSystemSkills: false,
collectCacheMaxEntries: 1.5,
})).rejects.toThrow('collectCacheMaxEntries')
})
it('renders no model listing when no model-invocable skills exist', async () => {
@@ -178,6 +342,16 @@ describe('SkillService', () => {
}
})
it('keeps constructor defaults when schema preprocessing is not involved', async () => {
const home = await tempDir('skill-constructor-defaults')
const service = new SkillService(new Context(), {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
})
expect((await service.list()).map(skill => skill.name)).toEqual(['dsh-plugin-creator', 'dsh-skill-creator'])
})
it('installs system skills into the DSH home without overwriting existing files', async () => {
const home = await tempDir('skill-install')
const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
@@ -202,7 +376,7 @@ describe('SkillService', () => {
await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Existing system skill\n---\n\nExisting body.\n')
const ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: home })
await ctx.plugin(TestFileSystem)
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
@@ -237,7 +411,7 @@ describe('SkillService', () => {
]))
const ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: home })
await ctx.plugin(TestFileSystem)
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
View File
@@ -8,6 +8,7 @@
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../fs/fs" },
{ "path": "../../llm/llm" },
{ "path": "../agent" }
+13 -1
View File
@@ -38,6 +38,15 @@ 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
@@ -51,12 +60,15 @@ export interface Config {
systemPrompt: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Skill discovery config forwarded to the shared agent-core spine. */
skills?: agentCore.SkillConfig
}
export const Config: z<Config> = z.object({
model: z.string().required(),
systemPrompt: z.string().required(),
persistenceRoot: z.string().default('./.sessions'),
skills: SkillConfigSchema,
})
/**
@@ -67,7 +79,7 @@ export const Config: z<Config> = z.object({
* stdout stays pure.
*/
export function apply(ctx: Context, config: Config): void {
ctx.plugin(agentCore)
ctx.plugin(agentCore, { agents: [], ...config.skills === undefined ? {} : { skills: config.skills } })
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt })
}
+16 -2
View File
@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest'
import { mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import * as acpAgent from '../src/index.ts'
@@ -22,9 +25,14 @@ async function mount(config: acpAgent.Config): Promise<Context> {
return ctx
}
async function isolatedSkillsConfig(): Promise<NonNullable<acpAgent.Config['skills']>> {
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-skills-'))
return { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }
}
describe('dsh-acp-agent composition', () => {
it('brings up the spine + persistence + the ACP bridge', async () => {
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' })
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig() })
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
@@ -40,12 +48,18 @@ describe('dsh-acp-agent composition', () => {
// `ctx.plugin`, which validates+defaults the config first) with no
// persistenceRoot, so the runtime fallback is the one that fires.
const ctx = new Context()
acpAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' })
acpAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
await new Promise(resolve => setTimeout(resolve, 50))
expect(ctx.get('sessionPersistence')).toBeDefined()
await ctx.fiber.dispose()
})
it('forwards skill config into agent-core', async () => {
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
expect(await ctx.skills.list()).toEqual([])
await ctx.fiber.dispose()
})
it('exposes its plugin shape', () => {
expect(acpAgent.name).toBe('acp-agent')
expect(acpAgent.Config).toBeDefined()
+12 -2
View File
@@ -120,7 +120,12 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js,
child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
cwd: consumer,
// Dummy key: initialize never reaches the model, so it is never used.
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
env: {
...process.env,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
DSH_HOME: join(consumer, '.dsh'),
DSH_AGENTS_HOME: join(consumer, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
})
const stderr: string[] = []
@@ -180,7 +185,12 @@ function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
cwd,
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
env: {
...process.env,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
})
child = proc
@@ -90,6 +90,8 @@ async function boot(): Promise<Spawned & { cwd: string }> {
TSX_TSCONFIG_PATH: repoTsconfig,
// Key-present check only; no prompt is sent, so the model is never called.
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},
stdio: ['pipe', 'pipe', 'pipe'],
},
+13
View File
@@ -49,6 +49,15 @@ 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`
@@ -66,6 +75,8 @@ export interface Config {
persistenceRoot?: string
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
welcome?: string
/** Skill discovery config forwarded to the shared agent-core spine. */
skills?: agentCore.SkillConfig
/**
* If set, the `main` agent RESUMES this persisted session id instead of
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
@@ -79,6 +90,7 @@ export const Config: z<Config> = z.object({
systemPrompt: z.string().required(),
persistenceRoot: z.string().default('./.sessions'),
welcome: z.string().default('ready.'),
skills: SkillConfigSchema,
resumeSessionId: z.string(),
})
@@ -99,6 +111,7 @@ export function apply(ctx: Context, config: Config): void {
cwd: process.cwd(),
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
}],
...config.skills !== undefined ? { skills: config.skills } : {},
})
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
@@ -110,7 +110,7 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st
const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
cwd,
// Mock model: never calls the network, so no key needed.
env: { ...process.env },
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdio: ['pipe', 'pipe', 'pipe'],
})
let stdout = ''
@@ -1,4 +1,7 @@
import { describe, it, expect } from 'vitest'
import { mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { AgentId } from '@deepseek-ai/dsh-agent'
@@ -28,9 +31,14 @@ async function mount(config: stdioAgent.Config): Promise<Context> {
return ctx
}
async function isolatedSkillsConfig(): Promise<NonNullable<stdioAgent.Config['skills']>> {
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-'))
return { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }
}
describe('dsh-stdio-agent app', () => {
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' })
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
// The spine services (brought up by the agent-core bundle) are all present.
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
@@ -48,7 +56,7 @@ describe('dsh-stdio-agent app', () => {
// apply()'s last two lines are the ones that fire — covering a
// schema-bypassing direct-mount caller.
const ctx = new Context()
stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' })
stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
await new Promise(resolve => setTimeout(resolve, 80))
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
@@ -64,11 +72,18 @@ describe('dsh-stdio-agent app', () => {
systemPrompt: 'hi',
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
resumeSessionId: 'no-such-session',
skills: await isolatedSkillsConfig(),
})
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('forwards skill config into agent-core', async () => {
const ctx = await mount({ model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
expect(await ctx.skills.list()).toEqual([])
await ctx.fiber.dispose()
})
it('exposes its name and Config schema', () => {
expect(stdioAgent.name).toBe('stdio-agent')
expect(stdioAgent.Config).toBeDefined()
+6
View File
@@ -222,6 +222,9 @@ importers:
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
schemastery:
specifier: ^3.18.0
version: 3.18.0
packages/core/agent-loop:
dependencies:
@@ -271,6 +274,9 @@ importers:
packages/core/skill:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
yaml:
specifier: ^2.4.2
version: 2.9.0
+11
View File
@@ -1,4 +1,5 @@
import tsconfigPaths from 'vite-tsconfig-paths'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
export default defineConfig({
@@ -18,6 +19,16 @@ 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: {