fix: harden skill discovery
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ interface Config {
|
||||
agentsHome?: string
|
||||
extraRoots?: string[]
|
||||
installSystemSkills?: boolean
|
||||
promptFieldMaxLength?: number
|
||||
collectCacheMaxEntries?: number
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../fs/fs" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../agent" }
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+6
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user