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>
|
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`
|
## `ctx.subagents` — `SubagentService`
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ interface Config {
|
|||||||
agentsHome?: string
|
agentsHome?: string
|
||||||
extraRoots?: string[]
|
extraRoots?: string[]
|
||||||
installSystemSkills?: boolean
|
installSystemSkills?: boolean
|
||||||
|
promptFieldMaxLength?: number
|
||||||
|
collectCacheMaxEntries?: number
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,16 @@ function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawn
|
|||||||
const child = spawn(
|
const child = spawn(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
['--import', tsxLoader, binScript, configPath],
|
['--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[] = []
|
const stderr: string[] = []
|
||||||
child.stderr.setEncoding('utf8')
|
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.
|
// which this purity test never triggers). So this runs WITHOUT real creds.
|
||||||
const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], {
|
const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], {
|
||||||
cwd: workdir,
|
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'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const SCENARIOS: Scenario[] = [
|
|||||||
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
|
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
|
||||||
{ name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
|
{ name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
|
||||||
{ name: 'todo-plan', 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: 'workspace-edit', hasModelTurn: true, recorded: true },
|
||||||
{ name: 'fs-read', hasModelTurn: true, recorded: true },
|
{ name: 'fs-read', hasModelTurn: true, recorded: true },
|
||||||
{ name: 'fs-write', 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: opts.mode,
|
||||||
DSH_SNAPSHOT_FILE: opts.fixtureFile,
|
DSH_SNAPSHOT_FILE: opts.fixtureFile,
|
||||||
DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
|
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.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {},
|
||||||
...opts.childFiles !== undefined && opts.childFiles.length > 0
|
...opts.childFiles !== undefined && opts.childFiles.length > 0
|
||||||
? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) }
|
? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) }
|
||||||
|
|||||||
7
examples/acp-agent/tests/snapshots/skill-load/input.json
Normal file
7
examples/acp-agent/tests/snapshots/skill-load/input.json
Normal file
@@ -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" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
28
examples/acp-agent/tests/snapshots/skill-load/session.jsonl
Normal file
28
examples/acp-agent/tests/snapshots/skill-load/session.jsonl
Normal file
@@ -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.
|
// 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.
|
// No prompt is sent, so the adapter never streams — no network call.
|
||||||
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
|
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
|
||||||
|
DSH_HOME: join(cwd, '.dsh'),
|
||||||
|
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||||
},
|
},
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
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
|
// requires it (mirrors the `demo:echo` script). The whole point is to boot
|
||||||
// the example EXACTLY as it really runs, through the bin + Loader.
|
// the example EXACTLY as it really runs, through the bin + Loader.
|
||||||
['--expose-internals', '--import', tsxLoader, binScript, configPath],
|
['--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
|
child = proc
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||||
"@deepseek-ai/dsh-tools": "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 type { Context } from 'cordis'
|
||||||
import Timer from '@cordisjs/plugin-timer'
|
import Timer from '@cordisjs/plugin-timer'
|
||||||
|
import z from 'schemastery'
|
||||||
|
import type Schema from 'schemastery'
|
||||||
import LlmService from '@deepseek-ai/dsh-llm'
|
import LlmService from '@deepseek-ai/dsh-llm'
|
||||||
import SessionStore from '@deepseek-ai/dsh-session'
|
import SessionStore from '@deepseek-ai/dsh-session'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
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 AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||||
import * as invariants from '@deepseek-ai/dsh-invariants'
|
import * as invariants from '@deepseek-ai/dsh-invariants'
|
||||||
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
|
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'
|
export const name = 'agent-core'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]`
|
* Bundle config: the agent-loop `agents` list plus skill discovery config.
|
||||||
* — an app that pre-creates no agents (the ACP bridge creates them on demand at
|
* Default `agents: []` means an app that pre-creates no agents (the ACP bridge
|
||||||
* `session/new`) simply omits it; an app that needs a pre-created `main` (the
|
* creates them on demand at `session/new`) can omit it; an app that needs a
|
||||||
* stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and
|
* pre-created `main` (the stdio chat) supplies one. `skills` is forwarded to
|
||||||
* the forwarded shape can never drift.
|
* {@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. */
|
/** Local schema for the forwarded skill config. Keep this in sync with `SkillService.Config`. */
|
||||||
export const Config = AgentLoop.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;
|
* 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(SessionStore)
|
||||||
ctx.plugin(SystemPrompt)
|
ctx.plugin(SystemPrompt)
|
||||||
ctx.plugin(ToolRegistry)
|
ctx.plugin(ToolRegistry)
|
||||||
ctx.plugin(SkillService)
|
ctx.plugin(SkillService, config.skills ?? {})
|
||||||
ctx.plugin(AgentRegistry)
|
ctx.plugin(AgentRegistry)
|
||||||
ctx.plugin(invariants)
|
ctx.plugin(invariants)
|
||||||
ctx.plugin(toolBash)
|
ctx.plugin(toolBash)
|
||||||
ctx.plugin(toolSkill)
|
ctx.plugin(toolSkill)
|
||||||
ctx.plugin(AgentLoop, { agents: config.agents })
|
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> {
|
async function mount(config?: agentCore.Config): Promise<Context> {
|
||||||
const oldDshHome = process.env.DSH_HOME
|
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_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()
|
const ctx = new Context()
|
||||||
try {
|
try {
|
||||||
await ctx.plugin(agentCore, config)
|
await ctx.plugin(agentCore, config)
|
||||||
@@ -33,6 +35,11 @@ async function mount(config?: agentCore.Config): Promise<Context> {
|
|||||||
} else {
|
} else {
|
||||||
process.env.DSH_HOME = oldDshHome
|
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()
|
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', () => {
|
it('re-exports the loop config schema as its own', () => {
|
||||||
expect(agentCore.Config).toBeDefined()
|
expect(agentCore.Config).toBeDefined()
|
||||||
expect(agentCore.name).toBe('agent-core')
|
expect(agentCore.name).toBe('agent-core')
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
{
|
{
|
||||||
"path": "../../../vendor/timer"
|
"path": "../../../vendor/timer"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "../../../vendor/schemastery"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "../../llm/llm"
|
"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.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.
|
- `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
|
### Discovery
|
||||||
|
|
||||||
Default roots are resolved in this conflict priority order:
|
Default roots are resolved in this conflict priority order:
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"cordis": "^4.0.0-rc.6"
|
"cordis": "^4.0.0-rc.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"schemastery": "^3.18.0",
|
||||||
"yaml": "^2.4.2"
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -8,18 +8,20 @@
|
|||||||
* @module @deepseek-ai/dsh-skill
|
* @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 { dirname, join, resolve } from 'node:path'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { Context, Service } from 'cordis'
|
import { Context, Service } from 'cordis'
|
||||||
|
import z from 'schemastery'
|
||||||
|
import type Schema from 'schemastery'
|
||||||
import { parse as parseYaml } from 'yaml'
|
import { parse as parseYaml } from 'yaml'
|
||||||
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
|
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||||
import type {} from '@deepseek-ai/dsh-agent'
|
import type {} from '@deepseek-ai/dsh-agent'
|
||||||
|
|
||||||
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||||
const MAX_PROMPT_FIELD_LENGTH = 500
|
const DEFAULT_PROMPT_FIELD_LENGTH = 500
|
||||||
const MAX_COLLECT_CACHE_ENTRIES = 128
|
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
|
||||||
|
|
||||||
export function isSkillName(name: string): boolean {
|
export function isSkillName(name: string): boolean {
|
||||||
return SKILL_NAME.test(name)
|
return SKILL_NAME.test(name)
|
||||||
@@ -55,9 +57,7 @@ export interface SkillDefinition extends SkillSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
|
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
|
||||||
export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & {
|
export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & { disableModelInvocation?: boolean }
|
||||||
disableModelInvocation?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Workspace selector used for cwd-sensitive project-root discovery. */
|
/** Workspace selector used for cwd-sensitive project-root discovery. */
|
||||||
export interface SkillLookupOptions {
|
export interface SkillLookupOptions {
|
||||||
@@ -68,12 +68,16 @@ export interface SkillLookupOptions {
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
|
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||||
dshHome?: string
|
dshHome?: string
|
||||||
/** Shared agent config root. Defaults to `~/.agents`. */
|
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
|
||||||
agentsHome?: string
|
agentsHome?: string
|
||||||
/** Extra skill roots, scanned after user roots and before system skills. */
|
/** Extra skill roots, scanned after user roots and before system skills. */
|
||||||
extraRoots?: string[]
|
extraRoots?: string[]
|
||||||
/** Ensure bundled system skills exist under `<dshHome>/skills/.system`. Defaults true. */
|
/** Ensure bundled system skills exist under `<dshHome>/skills/.system`. Defaults true. */
|
||||||
installSystemSkills?: boolean
|
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' {
|
declare module 'cordis' {
|
||||||
@@ -126,10 +130,21 @@ const SYSTEM_SKILLS: SkillDefinition[] = [
|
|||||||
* stable `## Skills` listing into each agent request.
|
* stable `## Skills` listing into each agent request.
|
||||||
*/
|
*/
|
||||||
export class SkillService extends Service {
|
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 dshHome: string
|
||||||
private readonly agentsHome: string
|
private readonly agentsHome: string
|
||||||
private readonly extraRoots: string[]
|
private readonly extraRoots: string[]
|
||||||
private readonly installSystemSkills: boolean
|
private readonly installSystemSkills: boolean
|
||||||
|
private readonly promptFieldMaxLength: number
|
||||||
|
private readonly collectCacheMaxEntries: number
|
||||||
private readonly runtime = new Map<string, SkillDefinition>()
|
private readonly runtime = new Map<string, SkillDefinition>()
|
||||||
private readonly collectCache = new Map<string, Promise<SkillDefinition[]>>()
|
private readonly collectCache = new Map<string, Promise<SkillDefinition[]>>()
|
||||||
private runtimeRevision = 0
|
private runtimeRevision = 0
|
||||||
@@ -138,9 +153,13 @@ export class SkillService extends Service {
|
|||||||
constructor(ctx: Context, config: Config = {}) {
|
constructor(ctx: Context, config: Config = {}) {
|
||||||
super(ctx, 'skills')
|
super(ctx, 'skills')
|
||||||
this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
|
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.extraRoots = (config.extraRoots ?? []).map(root => resolve(root))
|
||||||
this.installSystemSkills = config.installSystemSkills ?? true
|
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) {
|
if (this.installSystemSkills) {
|
||||||
const systemRoot = join(this.dshHome, 'skills/.system')
|
const systemRoot = join(this.dshHome, 'skills/.system')
|
||||||
this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => {
|
this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => {
|
||||||
@@ -208,8 +227,8 @@ export class SkillService extends Service {
|
|||||||
const entries = skills.map((skill) => {
|
const entries = skills.map((skill) => {
|
||||||
const lines = [
|
const lines = [
|
||||||
`<skill name="${escapeAttr(skill.name)}" source="${escapeAttr(skill.source)}">`,
|
`<skill name="${escapeAttr(skill.name)}" source="${escapeAttr(skill.source)}">`,
|
||||||
`description: ${promptLine(skill.description)}`,
|
`description: ${promptLine(skill.description, this.promptFieldMaxLength)}`,
|
||||||
...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse)}`] : [],
|
...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse, this.promptFieldMaxLength)}`] : [],
|
||||||
'</skill>',
|
'</skill>',
|
||||||
]
|
]
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
@@ -231,12 +250,16 @@ export class SkillService extends Service {
|
|||||||
if (cached !== undefined) return cached
|
if (cached !== undefined) return cached
|
||||||
|
|
||||||
const collected = this.collectFresh(roots)
|
const collected = this.collectFresh(roots)
|
||||||
this.collectCache.set(key, collected)
|
const cachedPromise = collected.catch((error: unknown) => {
|
||||||
if (this.collectCache.size > MAX_COLLECT_CACHE_ENTRIES) {
|
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>
|
const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
|
||||||
this.collectCache.delete(oldest.value)
|
this.collectCache.delete(oldest.value)
|
||||||
}
|
}
|
||||||
return collected
|
return cachedPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise<SkillDefinition[]> {
|
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))) {
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
if (root.skipSystem && entry.name === '.system') continue
|
if (root.skipSystem && entry.name === '.system') continue
|
||||||
const fullPath = join(root.path, entry.name)
|
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)
|
? 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)
|
? await parseSkillFile(fullPath, root.path, root.source, ctx)
|
||||||
: undefined
|
: undefined
|
||||||
if (parsed) skills.push(parsed)
|
if (parsed) skills.push(parsed)
|
||||||
@@ -341,7 +365,13 @@ async function parseSkillFile(path: string, directory: string, source: SkillSour
|
|||||||
if (raw === undefined) {
|
if (raw === undefined) {
|
||||||
return 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) {
|
if (!parsed) {
|
||||||
ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
|
ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
|
||||||
return undefined
|
return undefined
|
||||||
@@ -426,15 +456,48 @@ function fsReadErrorMessage(target: FsTarget, error: unknown): string {
|
|||||||
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
|
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 {
|
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
|
||||||
if (!raw.startsWith('---\n')) return undefined
|
const firstLineEnd = raw.indexOf('\n')
|
||||||
const end = raw.indexOf('\n---', 4)
|
if (firstLineEnd < 0) return undefined
|
||||||
if (end < 0) return undefined
|
const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
|
||||||
const yaml = raw.slice(4, end)
|
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
|
const parsed = parseYaml(yaml) as unknown
|
||||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
|
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: raw.slice(closing.bodyStart) }
|
||||||
return { data: parsed as Record<string, unknown>, body: body.startsWith('\n') ? body.slice(1) : body }
|
}
|
||||||
|
|
||||||
|
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> {
|
async function findProjectRoot(cwd: string): Promise<string> {
|
||||||
@@ -474,14 +537,20 @@ function compareSummary(left: SkillSummary, right: SkillSummary): number {
|
|||||||
return left.name.localeCompare(right.name)
|
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 normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||||
const truncated = normalized.length <= MAX_PROMPT_FIELD_LENGTH
|
const truncated = normalized.length <= maxLength
|
||||||
? normalized
|
? normalized
|
||||||
: `${normalized.slice(0, MAX_PROMPT_FIELD_LENGTH - 3)}...`
|
: `${normalized.slice(0, maxLength - 3)}...`
|
||||||
return escapeText(truncated)
|
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 {
|
function stringField(data: Record<string, unknown>, key: string): string | undefined {
|
||||||
const value = data[key]
|
const value = data[key]
|
||||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import SkillService from '@deepseek-ai/dsh-skill'
|
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> {
|
async function tempDir(name: string): Promise<string> {
|
||||||
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
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`)
|
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', () => {
|
describe('SkillService', () => {
|
||||||
it('discovers project, user, agents, and system skill roots in priority order', async () => {
|
it('discovers project, user, agents, and system skill roots in priority order', async () => {
|
||||||
const home = await tempDir('skill-home')
|
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/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/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/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/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/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---')
|
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()
|
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps skill body text that begins immediately after the closing frontmatter delimiter', async () => {
|
it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
|
||||||
const home = await tempDir('skill-frontmatter-body')
|
const home = await tempDir('skill-frontmatter-crlf')
|
||||||
const root = join(home, '.dsh/skills')
|
const root = join(home, '.dsh/skills')
|
||||||
await mkdir(root, { recursive: true })
|
await mkdir(root, { recursive: true })
|
||||||
await writeFile(join(root, 'tight-body.md'), [
|
await writeFile(join(root, 'crlf-skill.md'), [
|
||||||
'---',
|
'---',
|
||||||
'name: tight-body',
|
'name: crlf-skill',
|
||||||
'description: Tight body',
|
'description: CRLF skill',
|
||||||
'---First line must survive.',
|
'metadata:',
|
||||||
'Second line.',
|
' 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'))
|
].join('\n'))
|
||||||
|
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
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 () => {
|
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 () => {
|
it('installs system skills into the DSH home without overwriting existing files', async () => {
|
||||||
const home = await tempDir('skill-install')
|
const home = await tempDir('skill-install')
|
||||||
const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
|
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')
|
await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Existing system skill\n---\n\nExisting body.\n')
|
||||||
|
|
||||||
const ctx = new Context()
|
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') })
|
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||||
|
|
||||||
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
|
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
|
||||||
@@ -237,7 +411,7 @@ describe('SkillService', () => {
|
|||||||
]))
|
]))
|
||||||
|
|
||||||
const ctx = new Context()
|
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 })
|
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'])
|
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['text-skill'])
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../../vendor/cosmokit" },
|
{ "path": "../../../vendor/cosmokit" },
|
||||||
{ "path": "../../../vendor/cordis" },
|
{ "path": "../../../vendor/cordis" },
|
||||||
|
{ "path": "../../../vendor/schemastery" },
|
||||||
{ "path": "../../fs/fs" },
|
{ "path": "../../fs/fs" },
|
||||||
{ "path": "../../llm/llm" },
|
{ "path": "../../llm/llm" },
|
||||||
{ "path": "../agent" }
|
{ "path": "../agent" }
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
|||||||
|
|
||||||
export const name = 'acp-agent'
|
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`
|
* App config: the swappable per-deployment values. `model`/`systemPrompt`
|
||||||
* configure the agent template the ACP bridge creates each session's agent from
|
* configure the agent template the ACP bridge creates each session's agent from
|
||||||
@@ -51,12 +60,15 @@ export interface Config {
|
|||||||
systemPrompt: string
|
systemPrompt: string
|
||||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||||
persistenceRoot?: string
|
persistenceRoot?: string
|
||||||
|
/** Skill discovery config forwarded to the shared agent-core spine. */
|
||||||
|
skills?: agentCore.SkillConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Config: z<Config> = z.object({
|
export const Config: z<Config> = z.object({
|
||||||
model: z.string().required(),
|
model: z.string().required(),
|
||||||
systemPrompt: z.string().required(),
|
systemPrompt: z.string().required(),
|
||||||
persistenceRoot: z.string().default('./.sessions'),
|
persistenceRoot: z.string().default('./.sessions'),
|
||||||
|
skills: SkillConfigSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +79,7 @@ export const Config: z<Config> = z.object({
|
|||||||
* stdout stays pure.
|
* stdout stays pure.
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: Context, config: Config): void {
|
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(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||||
ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt })
|
ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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 { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
import * as acpAgent from '../src/index.ts'
|
import * as acpAgent from '../src/index.ts'
|
||||||
@@ -22,9 +25,14 @@ async function mount(config: acpAgent.Config): Promise<Context> {
|
|||||||
return ctx
|
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', () => {
|
describe('dsh-acp-agent composition', () => {
|
||||||
it('brings up the spine + persistence + the ACP bridge', async () => {
|
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('agents')).toBeDefined()
|
||||||
expect(ctx.get('sessions')).toBeDefined()
|
expect(ctx.get('sessions')).toBeDefined()
|
||||||
expect(ctx.get('sessionPersistence')).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
|
// `ctx.plugin`, which validates+defaults the config first) with no
|
||||||
// persistenceRoot, so the runtime fallback is the one that fires.
|
// persistenceRoot, so the runtime fallback is the one that fires.
|
||||||
const ctx = new Context()
|
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))
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||||
await ctx.fiber.dispose()
|
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', () => {
|
it('exposes its plugin shape', () => {
|
||||||
expect(acpAgent.name).toBe('acp-agent')
|
expect(acpAgent.name).toBe('acp-agent')
|
||||||
expect(acpAgent.Config).toBeDefined()
|
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'], {
|
child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
|
||||||
cwd: consumer,
|
cwd: consumer,
|
||||||
// Dummy key: initialize never reaches the model, so it is never used.
|
// 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'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
const stderr: string[] = []
|
const stderr: string[] = []
|
||||||
@@ -180,7 +185,12 @@ function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
|
const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
|
||||||
cwd,
|
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'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
child = proc
|
child = proc
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ async function boot(): Promise<Spawned & { cwd: string }> {
|
|||||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||||
// Key-present check only; no prompt is sent, so the model is never called.
|
// 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',
|
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'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ import * as uiStdio from './stdio-chat.ts'
|
|||||||
|
|
||||||
export const name = 'stdio-agent'
|
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
|
* App config: the swappable per-demo values, each routed to where the app wires
|
||||||
* it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main`
|
* it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main`
|
||||||
@@ -66,6 +75,8 @@ export interface Config {
|
|||||||
persistenceRoot?: string
|
persistenceRoot?: string
|
||||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||||
welcome?: string
|
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
|
* If set, the `main` agent RESUMES this persisted session id instead of
|
||||||
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
* 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(),
|
systemPrompt: z.string().required(),
|
||||||
persistenceRoot: z.string().default('./.sessions'),
|
persistenceRoot: z.string().default('./.sessions'),
|
||||||
welcome: z.string().default('ready.'),
|
welcome: z.string().default('ready.'),
|
||||||
|
skills: SkillConfigSchema,
|
||||||
resumeSessionId: z.string(),
|
resumeSessionId: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -99,6 +111,7 @@ export function apply(ctx: Context, config: Config): void {
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
|
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
|
||||||
}],
|
}],
|
||||||
|
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||||
})
|
})
|
||||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||||
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
|
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], {
|
const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
|
||||||
cwd,
|
cwd,
|
||||||
// Mock model: never calls the network, so no key needed.
|
// 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'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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 { Context } from 'cordis'
|
||||||
import Loader from '@cordisjs/plugin-loader'
|
import Loader from '@cordisjs/plugin-loader'
|
||||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||||
@@ -28,9 +31,14 @@ async function mount(config: stdioAgent.Config): Promise<Context> {
|
|||||||
return ctx
|
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', () => {
|
describe('dsh-stdio-agent app', () => {
|
||||||
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
|
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.
|
// The spine services (brought up by the agent-core bundle) are all present.
|
||||||
expect(ctx.get('agents')).toBeDefined()
|
expect(ctx.get('agents')).toBeDefined()
|
||||||
expect(ctx.get('agentLoop')).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
|
// apply()'s last two lines are the ones that fire — covering a
|
||||||
// schema-bypassing direct-mount caller.
|
// schema-bypassing direct-mount caller.
|
||||||
const ctx = new Context()
|
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))
|
await new Promise(resolve => setTimeout(resolve, 80))
|
||||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||||
@@ -64,11 +72,18 @@ describe('dsh-stdio-agent app', () => {
|
|||||||
systemPrompt: 'hi',
|
systemPrompt: 'hi',
|
||||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
|
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
|
||||||
resumeSessionId: 'no-such-session',
|
resumeSessionId: 'no-such-session',
|
||||||
|
skills: await isolatedSkillsConfig(),
|
||||||
})
|
})
|
||||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
||||||
await ctx.fiber.dispose()
|
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', () => {
|
it('exposes its name and Config schema', () => {
|
||||||
expect(stdioAgent.name).toBe('stdio-agent')
|
expect(stdioAgent.name).toBe('stdio-agent')
|
||||||
expect(stdioAgent.Config).toBeDefined()
|
expect(stdioAgent.Config).toBeDefined()
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -222,6 +222,9 @@ importers:
|
|||||||
cordis:
|
cordis:
|
||||||
specifier: ^4.0.0-rc.6
|
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)
|
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:
|
packages/core/agent-loop:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -271,6 +274,9 @@ importers:
|
|||||||
|
|
||||||
packages/core/skill:
|
packages/core/skill:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
schemastery:
|
||||||
|
specifier: ^3.18.0
|
||||||
|
version: 3.18.0
|
||||||
yaml:
|
yaml:
|
||||||
specifier: ^2.4.2
|
specifier: ^2.4.2
|
||||||
version: 2.9.0
|
version: 2.9.0
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -18,6 +19,16 @@ export default defineConfig({
|
|||||||
// upstream copies (vendor/README.md). The plugin's `projects` option
|
// upstream copies (vendor/README.md). The plugin's `projects` option
|
||||||
// instead applies the one root map to every importer.
|
// instead applies the one root map to every importer.
|
||||||
plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })],
|
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: {
|
test: {
|
||||||
include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'],
|
include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
Reference in New Issue
Block a user