feat(skill): move catalogs into session prefixes
This commit is contained in:
@@ -13,6 +13,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
|
||||
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
|
||||
@@ -156,7 +156,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'register(skill: SkillRegistration): () => void',
|
||||
'async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>',
|
||||
'async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>',
|
||||
'async renderModelListing(options: SkillLookupOptions = {}): Promise<string>',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -691,7 +690,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SkillLookupOptions',
|
||||
declaration: 'export interface SkillLookupOptions {\n cwd?: string | undefined;\n}',
|
||||
declaration: 'export interface SkillLookupOptions {\n cwd?: string | undefined;\n signal?: AbortSignal | undefined;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SkillProvider',
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
# core/ — product API spine
|
||||
|
||||
The packages every harness build is assembled from: the session log, the system-prompt assembly, the tool registry, the agent vocabulary, and the one concrete loop that drives them. These are **product** packages — the stable surface plugins and consumers build against.
|
||||
The session log, system-prompt assembly, tool registry, agent vocabulary, and concrete loop that form the harness's default control spine. These are **product** packages — the stable surface plugins and consumers build against.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
|
||||
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
|
||||
| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
|
||||
| `skill/` | Agent skill provider registry + request-time skill listing | `ctx.skills` |
|
||||
| `skill-local/` | Local filesystem skill provider | (registers on `ctx.skills`) |
|
||||
| `tool-skill/` | Model-facing `skill` loader tool | (registers on `ctx.tools`) |
|
||||
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
|
||||
| `agent-core/` | Bundle plugin: the default executor-less/UI-less spine as code | (loads the spine) |
|
||||
|
||||
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
|
||||
|
||||
`agent-core` is the composition counterpart: one bundle plugin that loads the default spine (`timer` + `llm` + sessions + system-prompt + tools + skill registry + local skill provider + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes the shared core while leaving executors, LLM adapters, non-local skill providers, and UI front doors outside the bundle.
|
||||
`agent-core` is the composition counterpart: one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes the shared control spine while leaving executors, LLM adapters, alternate skill providers, and UI front doors outside the bundle.
|
||||
|
||||
@@ -14,12 +14,12 @@ This is the package to read to see **the whole plugin tree at once** — the tea
|
||||
@deepseek-ai/dsh-session event-sourced session log + store
|
||||
@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly
|
||||
@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
|
||||
@deepseek-ai/dsh-skill skill provider registry + prompt listing
|
||||
@deepseek-ai/dsh-skill skill provider registry
|
||||
@deepseek-ai/dsh-skill-local local filesystem skill provider
|
||||
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
|
||||
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
|
||||
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas
|
||||
@deepseek-ai/dsh-tool-skill the model-facing skill loader schema
|
||||
@deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema
|
||||
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
|
||||
(dsh-system-prompt gets the forwarded `persona`)
|
||||
```
|
||||
@@ -43,7 +43,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-core'
|
||||
// so validation and defaulting can never drift from the owners.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section; `toolOrder` to `dsh-system-prompt` (absent — lexicographic), the explicit model-facing tool order; and `skills.registry` / `skills.local` to the skill registry and local provider. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` to `dsh-system-prompt` (default `''`), the deployment's persona section; `toolOrder` to `dsh-system-prompt` (absent — lexicographic), the explicit model-facing tool order; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure.
|
||||
|
||||
## Why a code bundle, not a shared YAML include
|
||||
|
||||
|
||||
@@ -62,12 +62,14 @@ import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agen
|
||||
|
||||
export const name = 'agent-core'
|
||||
|
||||
/** Skill bundle config forwarded to the registry and the local provider. */
|
||||
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
|
||||
export interface SkillConfig {
|
||||
/** Registry-level prompt/cache settings. */
|
||||
/** Registry-level discovery cache settings. */
|
||||
registry?: SkillRegistryConfig
|
||||
/** Local filesystem skill provider settings. */
|
||||
local?: SkillLocal.Config
|
||||
/** Model-facing skill catalog and tool settings. */
|
||||
tool?: toolSkill.Config
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +77,7 @@ export interface SkillConfig {
|
||||
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
|
||||
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||
* order), and `skills` to the skill registry/local provider. Every field is
|
||||
* order), and `skills` to the skill registry/local provider/tool consumer. Every field is
|
||||
* optional INPUT here because each owner's schema supplies the default (`[]` /
|
||||
* `''` / absent — lexicographic / the DSH skill roots); the schema is the
|
||||
* INTERSECTION of the owners' own schemas, so validation and defaulting can
|
||||
@@ -88,7 +90,7 @@ export interface Config {
|
||||
persona?: SystemPromptConfig['persona']
|
||||
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
|
||||
toolOrder?: SystemPromptConfig['toolOrder']
|
||||
/** Skill registry and local provider config. */
|
||||
/** Skill registry, local provider, and model-facing consumer config. */
|
||||
skills?: SkillConfig
|
||||
}
|
||||
|
||||
@@ -96,6 +98,7 @@ export interface Config {
|
||||
export const SkillConfigSchema: z<SkillConfig> = z.object({
|
||||
registry: SkillService.Config,
|
||||
local: SkillLocal.Config,
|
||||
tool: toolSkill.Config,
|
||||
})
|
||||
|
||||
/** Intersect the owners' schemas so validation + defaulting stay identical. */
|
||||
@@ -134,6 +137,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(AgentRegistry)
|
||||
ctx.plugin(invariants)
|
||||
ctx.plugin(toolBash)
|
||||
ctx.plugin(toolSkill)
|
||||
ctx.plugin(toolSkill, config.skills?.tool ?? {})
|
||||
ctx.plugin(AgentLoop, { agents: config.agents ?? [] })
|
||||
}
|
||||
|
||||
@@ -7,6 +7,15 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as agentCore from '../src/index.ts'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', { session: { header: { cwd } } } as never,
|
||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings
|
||||
@@ -120,7 +129,7 @@ describe('dsh-agent-core bundle', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config to the registry and local provider', async () => {
|
||||
it('forwards skill config to the registry, local provider, and model-facing consumer', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-home-'))
|
||||
const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-agents-'))
|
||||
const custom = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-custom-'))
|
||||
@@ -129,16 +138,17 @@ describe('dsh-agent-core bundle', () => {
|
||||
const ctx = await mount({
|
||||
agents: [],
|
||||
skills: {
|
||||
registry: { promptFieldMaxLength: 6 },
|
||||
registry: { collectCacheMaxEntries: 4 },
|
||||
local: {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(agentsHome, '.agents'),
|
||||
customSkillDirs: [custom],
|
||||
},
|
||||
tool: { catalogDescriptionMaxLength: 6 },
|
||||
},
|
||||
})
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['custom-skill'])
|
||||
expect(await ctx.skills.renderModelListing()).toContain('description: Cus...')
|
||||
expect(JSON.stringify(await composePrefix(ctx, '/tmp'))).toContain('- `custom-skill`: Cus...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -33,13 +33,13 @@
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/skill"
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../core/skill-local"
|
||||
"path": "../../skill/skill-local"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tool-skill"
|
||||
"path": "../../skill/tool-skill"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# @deepseek-ai/dsh-skill
|
||||
|
||||
Agent skill provider registry and model-facing skill guidance.
|
||||
|
||||
This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local).
|
||||
|
||||
## Service: `SkillService` (ctx key: `skills`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registration is effect-scoped and HMR-safe.
|
||||
- `ctx.skills.list({ cwd? })` Returns model-invocable skill summaries for the current workspace, merged across providers.
|
||||
- `ctx.skills.get(name, { cwd? })` Returns the full winning skill, including disabled-for-model skills.
|
||||
- `ctx.skills.register(skill): () => void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer.
|
||||
- `ctx.skills.renderModelListing({ cwd? })` Renders the request-time `## Skills` catalog.
|
||||
|
||||
### Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `promptFieldMaxLength` | `500` | Maximum rendered `description` / `whenToUse` length in the prompt listing; must be at least `3` because truncated fields reserve `...`. |
|
||||
| `collectCacheMaxEntries` | `128` | Maximum cwd/provider discovery promises kept in memory. |
|
||||
|
||||
## Provider Contract
|
||||
|
||||
A provider returns `SkillCandidate[]` from `list(options)` and later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a future HTTP provider can store a URL, id, or version token.
|
||||
|
||||
The registry validates candidate names, descriptions, ranks, and provider ownership. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final model-visible summary list is sorted by skill `name` for deterministic prompt text and provider prefix-cache friendliness.
|
||||
|
||||
## Runtime Skills
|
||||
|
||||
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
|
||||
|
||||
## Prompt Integration
|
||||
|
||||
The service listens on `system-prompt/assemble` and appends a short `## Skills` section to the calling agent's assembled system prompt. The listing contains only stable routing metadata (`name`, `source`, `description`, and optional `whenToUse`), not skill bodies or absolute local paths. `description` and `whenToUse` are whitespace-normalized, capped, XML-escaped, and have `{{` / `}}` delimiters split so provider text cannot trip prompt-variable interpolation. Models load full instructions through the `skill` tool.
|
||||
|
||||
The prompt-injection surface is intentionally separate from provider loading: changing where skills come from means adding or swapping providers, not changing prompt assembly or the `skill` tool.
|
||||
@@ -1,15 +0,0 @@
|
||||
# @deepseek-ai/dsh-tool-skill
|
||||
|
||||
The model-facing `skill` tool for loading full skill instructions.
|
||||
|
||||
Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`).
|
||||
|
||||
## Tool: `skill`
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | string (required) | Exact kebab-case skill name from the available skills listing. |
|
||||
|
||||
Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers can resolve the right winning skill. A successful call returns a text block containing `<skill_content name="...">`, the skill body, and provider resource guidance. Local filesystem skills include a base directory for resolving relative files; remote or embedded providers can return URL or opaque provider-managed guidance instead. Unknown names, invalid names, and skills marked `disableModelInvocation: true` return `isError` tool results through the normal tool registry error path.
|
||||
|
||||
The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context.
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Model-facing `skill` tool.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-skill
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { isSkillName, type SkillDefinition } from '@deepseek-ai/dsh-skill'
|
||||
|
||||
export const name = 'tool-skill'
|
||||
export const inject = ['tools', 'skills']
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
const skillTool = defineTool({
|
||||
name: 'skill',
|
||||
description: 'Load the full instructions for one available skill by name. Use this when the current task matches a skill listed in the system prompt.',
|
||||
parameters: {
|
||||
name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (!isSkillName(args.name)) {
|
||||
throw new Error(`invalid skill name "${args.name}"`)
|
||||
}
|
||||
const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd })
|
||||
if (!skill) {
|
||||
throw new Error(`unknown skill "${args.name}"`)
|
||||
}
|
||||
if (skill.disableModelInvocation === true) {
|
||||
throw new Error(`skill "${args.name}" is not available for model invocation`)
|
||||
}
|
||||
return [{ type: 'text', text: renderSkillContent(skill) }]
|
||||
},
|
||||
presentCall(args) {
|
||||
return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
|
||||
},
|
||||
})
|
||||
ctx.tools.register(skillTool)
|
||||
}
|
||||
|
||||
function renderSkillContent(skill: SkillDefinition): string {
|
||||
const resourceHint = renderResourceHint(skill)
|
||||
return [
|
||||
`<skill_content name="${skill.name}">`,
|
||||
`# Skill: ${skill.name}`,
|
||||
'',
|
||||
skill.content,
|
||||
'',
|
||||
...resourceHint,
|
||||
'</skill_content>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function renderResourceHint(skill: SkillDefinition): string[] {
|
||||
const base = skill.resourceBase
|
||||
if (base === undefined) {
|
||||
return [`Resources for this skill are managed by provider "${skill.provider}".`]
|
||||
}
|
||||
switch (base.kind) {
|
||||
case 'directory':
|
||||
return [
|
||||
`Base directory for this skill: ${base.path}`,
|
||||
'Resolve relative files mentioned by this skill against the base directory before using them.',
|
||||
]
|
||||
case 'url':
|
||||
return [`Base URL for this skill: ${base.url}`]
|
||||
case 'opaque':
|
||||
return [`Resources for this skill: ${base.description}`]
|
||||
default:
|
||||
return assertNever(base, 'SkillResourceBase.kind')
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
async function tempDir(name: string): Promise<string> {
|
||||
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {
|
||||
const dir = join(root, name)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
|
||||
}
|
||||
|
||||
async function setup(home: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
await ctx.plugin(toolSkill)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('dsh-tool-skill', () => {
|
||||
it('registers the skill tool schema and removes it on dispose', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const home = await tempDir('tool-schema')
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
|
||||
const fiber = await ctx.plugin(toolSkill)
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
|
||||
expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({
|
||||
card: 'generic',
|
||||
title: 'Load skill project-skill',
|
||||
kind: 'read',
|
||||
rawInput: 'project-skill',
|
||||
})
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toEqual([])
|
||||
})
|
||||
|
||||
it('loads a skill for the calling agent cwd', async () => {
|
||||
const home = await tempDir('tool-load')
|
||||
const project = await tempDir('tool-project')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.')
|
||||
const ctx = await setup(home)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('c1'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'project-skill' },
|
||||
agent: { session: { header: { cwd: project } } } as never,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const block = result.content[0]
|
||||
expect(block?.type).toBe('text')
|
||||
if (block?.type !== 'text') throw new Error('expected text skill result')
|
||||
expect(block.text).toContain('<skill_content name="project-skill">')
|
||||
expect(block.text).toContain('Project instructions.')
|
||||
})
|
||||
|
||||
it('renders provider-managed resource hints for non-local skills', async () => {
|
||||
const home = await tempDir('tool-resource-hints')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'opaque-skill',
|
||||
description: 'Opaque skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'opaque', description: 'runtime memory' },
|
||||
content: 'Opaque instructions.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'url-skill',
|
||||
description: 'URL skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'url', url: 'https://skills.example.test/url-skill' },
|
||||
content: 'URL instructions.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'provider-skill',
|
||||
description: 'Provider skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'Provider instructions.',
|
||||
})
|
||||
|
||||
const opaque = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } })
|
||||
const url = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } })
|
||||
const provider = await ctx.tools.execute({ callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } })
|
||||
|
||||
if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') {
|
||||
throw new Error('expected text tool results')
|
||||
}
|
||||
expect(opaque.content[0].text).toContain('Resources for this skill: runtime memory')
|
||||
expect(url.content[0].text).toContain('Base URL for this skill: https://skills.example.test/url-skill')
|
||||
expect(provider.content[0].text).toContain('Resources for this skill are managed by provider "runtime"')
|
||||
})
|
||||
|
||||
it('fails loud on an unknown resource base kind', async () => {
|
||||
const home = await tempDir('tool-resource-assert-never')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'rogue-resource-skill',
|
||||
description: 'Rogue resource skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'future' } as never,
|
||||
content: 'Rogue instructions.',
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
const block = result.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(block.text).toContain('unreachable variant')
|
||||
})
|
||||
|
||||
it('returns isError for unknown, invalid, and model-disabled skills', async () => {
|
||||
const home = await tempDir('tool-errors')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
|
||||
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
|
||||
const ctx = await setup(home)
|
||||
|
||||
const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
|
||||
const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
|
||||
const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
|
||||
|
||||
expect(unknown.isError).toBe(true)
|
||||
expect(invalid.isError).toBe(true)
|
||||
expect(disabled.isError).toBe(true)
|
||||
})
|
||||
})
|
||||
11
packages/skill/README.md
Normal file
11
packages/skill/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# skill/ - skill capability family
|
||||
|
||||
The canonical three-package capability seam for reusable agent instructions: a provider registry, a local implementation, and the model-facing catalog/loader consumer. All are **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `skill/` | Provider registry, precedence resolution, stable catalog snapshots, and full-definition lookup | `ctx.skills` |
|
||||
| `skill-local/` | Project/custom/user filesystem provider | (registers on `ctx.skills`) |
|
||||
| `tool-skill/` | Session-prefix catalog and model-facing `skill` loader | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `skill/skill/`. Providers register synchronously and perform asynchronous discovery through `ctx.skills`; `tool-skill` consumes only that interface, so an embedded or remote provider can replace or complement `skill-local` without changing the model-facing contract. `agent-core` loads this family by default, but it remains a capability outside the core control spine, parallel to [`bash/`](../bash/README.md), [`fs/`](../fs/README.md), [`web/`](../web/README.md), and [`subagent/`](../subagent/README.md).
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Local filesystem provider for the `ctx.skills` registry.
|
||||
|
||||
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry, prompt listing, and model-facing loader tool remain in `@deepseek-ai/dsh-skill` and `@deepseek-ai/dsh-tool-skill`.
|
||||
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the session-prefix catalog and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
|
||||
|
||||
## Plugin
|
||||
|
||||
34
packages/skill/skill/README.md
Normal file
34
packages/skill/skill/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-skill
|
||||
|
||||
Pure agent skill provider registry.
|
||||
|
||||
This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local).
|
||||
|
||||
## Service: `SkillService` (ctx key: `skills`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.skills.registerProvider(provider): () => void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registration is effect-scoped and HMR-safe.
|
||||
- `ctx.skills.list({ cwd?, signal? })` Returns model-invocable skill summaries for the current workspace, merged across providers and sorted by name.
|
||||
- `ctx.skills.get(name, { cwd?, signal? })` Returns the full winning skill, including disabled-for-model skills.
|
||||
- `ctx.skills.register(skill): () => void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer.
|
||||
|
||||
### Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `collectCacheMaxEntries` | `128` | Maximum completed cwd/provider catalog snapshots kept in memory. |
|
||||
|
||||
## Provider Contract
|
||||
|
||||
A provider registers synchronously from its `apply()` and returns `SkillCandidate[]` from `list(options)` when discovery is requested. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting an uncooperative provider so agent cancellation cannot hang prefix composition. The provider later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a remote provider can store a URL, id, or version token.
|
||||
|
||||
The registry validates candidate names, descriptions, ranks, and provider ownership. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers.
|
||||
|
||||
## Runtime Skills
|
||||
|
||||
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
|
||||
|
||||
## Consumer boundary
|
||||
|
||||
The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide the session-prefix catalog and `skill` tool, so providers remain independent of the model surface.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-skill",
|
||||
"description": "Agent skill provider registry and prompt listing for the DeepSeek Harness",
|
||||
"description": "Agent skill provider registry for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -22,16 +22,12 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Agent skill registry and request-time catalog rendering.
|
||||
* Agent skill provider registry.
|
||||
*
|
||||
* This package is the interface third of the skill capability seam. Concrete
|
||||
* providers such as `@deepseek-ai/dsh-skill-local` decide where skills come
|
||||
* from; this service only merges provider catalogs, resolves the winning skill
|
||||
* for a name, and exposes the model-facing catalog/tool consumers use.
|
||||
* for a name, and exposes the winning summaries and definitions to consumers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-skill
|
||||
*/
|
||||
@@ -12,15 +12,11 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type Schema from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
const DEFAULT_PROMPT_FIELD_LENGTH = 500
|
||||
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
|
||||
const RUNTIME_PROVIDER = 'runtime'
|
||||
const RUNTIME_RANK = 250
|
||||
const SKILL_PROMPT_SECTION_ORDER = 1000
|
||||
|
||||
/**
|
||||
* Return whether a string is a valid kebab-case skill name.
|
||||
@@ -83,9 +79,11 @@ export interface SkillDefinition extends SkillSummary {
|
||||
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
|
||||
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { provider?: string }
|
||||
|
||||
/** Workspace selector used for cwd-sensitive provider discovery. */
|
||||
/** Caller context used for cwd-sensitive and abortable provider work. */
|
||||
export interface SkillLookupOptions {
|
||||
cwd?: string | undefined
|
||||
/** Abort discovery or loading work for the current caller. */
|
||||
signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
/** Provider interface for one source of skills, such as local directories or a remote registry. */
|
||||
@@ -93,15 +91,18 @@ export interface SkillProvider {
|
||||
/** Unique provider name in the `ctx.skills` registry. */
|
||||
name: string
|
||||
/**
|
||||
* List available skill candidates for the current lookup context.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
|
||||
* List available skill candidates for the current lookup context. Provider
|
||||
* plugins register synchronously during `apply()`; remote initialization,
|
||||
* authentication, and discovery are awaited inside this method. Implementations
|
||||
* should settle promptly when `options.signal` aborts.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns provider candidates with precedence ranks and opaque locators.
|
||||
*/
|
||||
list(options: SkillLookupOptions): Promise<SkillCandidate[]>
|
||||
/**
|
||||
* Load a complete skill body for a previously listed candidate.
|
||||
* @param candidate - the winning candidate originally returned by this provider.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns the full skill body, or `undefined` if it is no longer loadable.
|
||||
*/
|
||||
get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>
|
||||
@@ -109,9 +110,7 @@ export interface SkillProvider {
|
||||
|
||||
/** Skill registry configuration. */
|
||||
export interface Config {
|
||||
/** Maximum rendered description/whenToUse length in the prompt listing; minimum 3. */
|
||||
promptFieldMaxLength?: number
|
||||
/** Maximum number of cwd/provider discovery promises kept in the in-memory cache. */
|
||||
/** Maximum number of completed cwd/provider catalog snapshots kept in memory. */
|
||||
collectCacheMaxEntries?: number
|
||||
}
|
||||
|
||||
@@ -152,52 +151,35 @@ interface CollectResult {
|
||||
|
||||
/**
|
||||
* Registry of skill providers. It merges provider catalogs with stable
|
||||
* first-wins duplicate handling, exposes sorted model-visible summaries, loads
|
||||
* full skill bodies on demand, and renders the request-time catalog fragment.
|
||||
* first-wins duplicate handling, exposes sorted model-visible summaries, and
|
||||
* loads full skill bodies on demand.
|
||||
*/
|
||||
export class SkillService extends Service {
|
||||
static Config: Schema<Config> = z.object({
|
||||
promptFieldMaxLength: z.number().default(DEFAULT_PROMPT_FIELD_LENGTH),
|
||||
collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
|
||||
})
|
||||
|
||||
private readonly promptFieldMaxLength: number
|
||||
private readonly collectCacheMaxEntries: number
|
||||
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
|
||||
private readonly runtime = new Map<string, SkillDefinition>()
|
||||
private readonly collectCache = new Map<string, Promise<IndexedCandidate[]>>()
|
||||
private readonly collectCache = new Map<string, IndexedCandidate[]>()
|
||||
private providerRevision = 0
|
||||
private nextProviderOrder = 0
|
||||
private runtimeRevision = 0
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'skills')
|
||||
this.promptFieldMaxLength = config.promptFieldMaxLength ?? DEFAULT_PROMPT_FIELD_LENGTH
|
||||
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
|
||||
assertPositiveInteger('promptFieldMaxLength', this.promptFieldMaxLength, 3)
|
||||
assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
|
||||
|
||||
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
const result = await next()
|
||||
const agent = context.agent
|
||||
if (agent === undefined) return result
|
||||
const listing = await this.renderModelListing({ cwd: agent.session.header.cwd })
|
||||
if (listing.length > 0) {
|
||||
result.sections.push({
|
||||
name: 'skills:available',
|
||||
order: SKILL_PROMPT_SECTION_ORDER,
|
||||
text: listing,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a skill provider. Throws if another provider already owns the same
|
||||
* provider name, including the reserved runtime provider name. Effect-scoped
|
||||
* and HMR-safe: disposing the caller's fiber unregisters the provider and
|
||||
* invalidates cached catalogs.
|
||||
* Register a skill provider synchronously during the provider plugin's
|
||||
* `apply()`. Throws if another provider already owns the same provider name,
|
||||
* including the reserved runtime provider name. Providers that need remote
|
||||
* initialization do that work inside `list()` after registration. Effect-
|
||||
* scoped and HMR-safe: disposing the caller's fiber unregisters the provider
|
||||
* and invalidates cached catalogs.
|
||||
* @param provider - the provider to register by `provider.name`.
|
||||
* @returns a disposer that unregisters this provider.
|
||||
*/
|
||||
@@ -252,7 +234,7 @@ export class SkillService extends Service {
|
||||
|
||||
/**
|
||||
* List model-invocable skill summaries for a workspace.
|
||||
* @param options - lookup options; `cwd` selects the project roots to scan.
|
||||
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
* @returns sorted summaries, excluding skills disabled for model invocation.
|
||||
*/
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
|
||||
@@ -260,13 +242,13 @@ export class SkillService extends Service {
|
||||
.map(entry => entry.candidate)
|
||||
.filter(skill => skill.disableModelInvocation !== true)
|
||||
.map(toSummary)
|
||||
.sort(compareSummary)
|
||||
.sort(compareSkillSummary)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one full skill definition by name.
|
||||
* @param name - kebab-case skill name.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
* @returns the full skill, including body content, or `undefined`.
|
||||
*/
|
||||
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
|
||||
@@ -276,51 +258,27 @@ export class SkillService extends Service {
|
||||
return await match.provider.get(match.candidate, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the request-time `## Skills` prompt fragment.
|
||||
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
|
||||
* @returns an empty string when no model-invocable skills are available.
|
||||
*/
|
||||
async renderModelListing(options: SkillLookupOptions = {}): Promise<string> {
|
||||
const skills = await this.list(options)
|
||||
if (skills.length === 0) return ''
|
||||
const entries = skills.map((skill) => {
|
||||
const lines = [
|
||||
`<skill name="${escapeAttr(skill.name)}" source="${escapeAttr(skill.source)}">`,
|
||||
`description: ${promptLine(skill.description, this.promptFieldMaxLength)}`,
|
||||
...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse, this.promptFieldMaxLength)}`] : [],
|
||||
'</skill>',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}).join('\n')
|
||||
return [
|
||||
'## Skills',
|
||||
'Available skills are listed below. Load a skill with the `skill` tool before following its instructions; do not infer or follow instructions from a skill body that has not been loaded.',
|
||||
'<available_skills>',
|
||||
entries,
|
||||
'</available_skills>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
|
||||
const key = collectCacheKey(options, this.providerRevision, this.runtimeRevision)
|
||||
const cached = this.collectCache.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
options.signal?.throwIfAborted()
|
||||
while (true) {
|
||||
const providerRevision = this.providerRevision
|
||||
const runtimeRevision = this.runtimeRevision
|
||||
const key = collectCacheKey(options, providerRevision, runtimeRevision)
|
||||
const cached = this.collectCache.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const collected = this.collectFresh(options)
|
||||
const cachedPromise = collected.then((result) => {
|
||||
if (!result.cacheable) this.collectCache.delete(key)
|
||||
const result = await this.collectFresh(options)
|
||||
options.signal?.throwIfAborted()
|
||||
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) continue
|
||||
if (result.cacheable) {
|
||||
this.collectCache.set(key, result.entries)
|
||||
if (this.collectCache.size > this.collectCacheMaxEntries) {
|
||||
const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
|
||||
this.collectCache.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
return result.entries
|
||||
}).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 cachedPromise
|
||||
}
|
||||
|
||||
private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
@@ -341,10 +299,11 @@ export class SkillService extends Service {
|
||||
}
|
||||
|
||||
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
|
||||
options.signal?.throwIfAborted()
|
||||
const candidates: IndexedCandidate[] = []
|
||||
let cacheable = true
|
||||
let runtimeOrder = 0
|
||||
for (const skill of [...this.runtime.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
for (const skill of [...this.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
|
||||
candidates.push({
|
||||
candidate: runtimeCandidate(skill),
|
||||
provider: RUNTIME_SKILL_PROVIDER,
|
||||
@@ -353,13 +312,16 @@ export class SkillService extends Service {
|
||||
})
|
||||
runtimeOrder += 1
|
||||
}
|
||||
for (const { provider, order } of this.providers.values()) {
|
||||
for (const { provider, order } of [...this.providers.values()]) {
|
||||
let localOrder = 0
|
||||
const listed = await provider.list(options).catch((error: unknown) => {
|
||||
let listed: SkillCandidate[] | undefined
|
||||
try {
|
||||
listed = await waitWithAbort(provider.list(options), options.signal)
|
||||
} catch (error) {
|
||||
if (options.signal?.aborted === true) throw toError(options.signal.reason)
|
||||
cacheable = false
|
||||
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
if (listed === undefined) continue
|
||||
for (const candidate of listed) {
|
||||
validateCandidate(candidate, provider.name)
|
||||
@@ -436,8 +398,14 @@ function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
|
||||
}
|
||||
}
|
||||
|
||||
function compareSummary(left: SkillSummary, right: SkillSummary): number {
|
||||
return left.name.localeCompare(right.name)
|
||||
function compareSkillSummary(left: SkillSummary, right: SkillSummary): number {
|
||||
return compareCodePoints(left.name, right.name)
|
||||
}
|
||||
|
||||
function compareCodePoints(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function compareIndexedCandidates(left: IndexedCandidate, right: IndexedCandidate): number {
|
||||
@@ -446,36 +414,46 @@ function compareIndexedCandidates(left: IndexedCandidate, right: IndexedCandidat
|
||||
|| left.localOrder - right.localOrder
|
||||
}
|
||||
|
||||
function promptLine(value: string, maxLength: number): string {
|
||||
const normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||
const truncated = normalized.length <= maxLength
|
||||
? normalized
|
||||
: `${normalized.slice(0, maxLength - 3)}...`
|
||||
return escapeText(breakPromptTemplateDelimiters(truncated))
|
||||
}
|
||||
|
||||
function breakPromptTemplateDelimiters(value: string): string {
|
||||
return value.replaceAll('{{', '{ {').replaceAll('}}', '} }')
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
if (!Number.isInteger(value) || value < minimum) {
|
||||
throw new Error(`skill: ${name} must be an integer greater than or equal to ${minimum}`)
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<')
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
}
|
||||
|
||||
function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string {
|
||||
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
|
||||
}
|
||||
|
||||
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (signal === undefined) return promise
|
||||
signal.throwIfAborted()
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const cleanup = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
cleanup()
|
||||
reject(toError(signal.reason))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void promise.then(
|
||||
(value) => {
|
||||
cleanup()
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
cleanup()
|
||||
reject(toError(error))
|
||||
},
|
||||
)
|
||||
if (signal.aborted) onAbort()
|
||||
})
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return String(error)
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
function agentForCwd(cwd: string): never {
|
||||
return { session: { header: { cwd } } } as never
|
||||
}
|
||||
|
||||
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
|
||||
return {
|
||||
@@ -113,6 +108,9 @@ describe('SkillService registry', () => {
|
||||
})
|
||||
|
||||
it('validates provider candidates and invalid registry caps', async () => {
|
||||
const defaultedService = new SkillService(new Context())
|
||||
expect(await defaultedService.list()).toEqual([])
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider({
|
||||
@@ -146,10 +144,33 @@ describe('SkillService registry', () => {
|
||||
await expect(invalid.skills.list()).rejects.toThrow('skill provider')
|
||||
}
|
||||
|
||||
await expect(new Context().plugin(SkillService, { promptFieldMaxLength: 2 })).rejects.toThrow('greater than or equal to 3')
|
||||
await expect(new Context().plugin(SkillService, { collectCacheMaxEntries: 1.5 })).rejects.toThrow('collectCacheMaxEntries')
|
||||
})
|
||||
|
||||
it('sorts model-visible summaries without locale-sensitive collation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
ctx.skills.registerProvider(new MemoryProvider([
|
||||
memorySkill('z-skill', 'Z skill', 10),
|
||||
memorySkill('a-skill', 'A skill', 10),
|
||||
]))
|
||||
const localeCompare = vi.spyOn(String.prototype, 'localeCompare')
|
||||
const sort = vi.spyOn(Array.prototype, 'sort')
|
||||
|
||||
try {
|
||||
const skills = await ctx.skills.list()
|
||||
expect(skills.map(skill => skill.name)).toEqual(['a-skill', 'z-skill'])
|
||||
expect(localeCompare).not.toHaveBeenCalled()
|
||||
|
||||
const summaryComparator = sort.mock.calls.at(-1)?.[0]
|
||||
expect(summaryComparator).toBeTypeOf('function')
|
||||
expect(summaryComparator?.(skills[0], skills[0])).toBe(0)
|
||||
} finally {
|
||||
sort.mockRestore()
|
||||
localeCompare.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('caches provider discovery, skips failing providers, and invalidates on runtime skills', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 })
|
||||
@@ -203,43 +224,106 @@ describe('SkillService registry', () => {
|
||||
expect(flakyCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('renders stable prompt guidance and omits it when no skills exist', async () => {
|
||||
it('abandons an in-flight catalog when provider registrations change', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, { persona: 'base' })
|
||||
await ctx.plugin(SkillService, { promptFieldMaxLength: 6 })
|
||||
ctx.skills.registerProvider(new MemoryProvider([
|
||||
{
|
||||
...memorySkill('escaped-skill', 'Use </available_skills><oops> safely', 10),
|
||||
whenToUse: 'Handle <tag> & marker',
|
||||
await ctx.plugin(SkillService)
|
||||
let markStarted: (() => void) | undefined
|
||||
let release: (() => void) | undefined
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const dispose = ctx.skills.registerProvider({
|
||||
name: 'delayed',
|
||||
async list() {
|
||||
markStarted?.()
|
||||
await gate
|
||||
return [{ ...memorySkill('stale-skill', 'Stale', 10), provider: 'delayed' }]
|
||||
},
|
||||
]))
|
||||
async get(candidate) {
|
||||
return { ...candidate, content: 'Stale body.' }
|
||||
},
|
||||
})
|
||||
|
||||
const listing = await ctx.skills.renderModelListing()
|
||||
expect(listing).toContain('description: Use...')
|
||||
expect(listing).toContain('whenToUse: Han...')
|
||||
expect(listing).not.toContain('</available_skills><oops>')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/tmp') }))).toContain('## Skills')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('## Skills')
|
||||
const pending = ctx.skills.list()
|
||||
await started
|
||||
dispose()
|
||||
release?.()
|
||||
|
||||
const empty = new Context()
|
||||
await empty.plugin(SystemPrompt, { persona: 'base' })
|
||||
await empty.plugin(SkillService)
|
||||
expect(await empty.skills.renderModelListing()).toBe('')
|
||||
expect(renderPrompt(await empty.systemPrompt.assemble({ agent: agentForCwd('/tmp') }))).not.toContain('## Skills')
|
||||
expect(await pending).toEqual([])
|
||||
})
|
||||
|
||||
const direct = new SkillService(new Context(), {})
|
||||
expect(await direct.renderModelListing()).toBe('')
|
||||
const short = new Context()
|
||||
await short.plugin(SkillService)
|
||||
short.skills.registerProvider(new MemoryProvider([memorySkill('short-skill', 'Short', 10)]))
|
||||
expect(await short.skills.renderModelListing()).toContain('description: Short')
|
||||
it('stops waiting for discovery when its lookup signal aborts', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
let markStarted: (() => void) | undefined
|
||||
let release: (() => void) | undefined
|
||||
let seenSignal: AbortSignal | undefined
|
||||
const started = new Promise<void>((resolve) => { markStarted = resolve })
|
||||
const held = new Promise<SkillCandidate[]>((resolve) => {
|
||||
release = () => { resolve([]) }
|
||||
})
|
||||
ctx.skills.registerProvider({
|
||||
name: 'uncooperative',
|
||||
list(options) {
|
||||
seenSignal = options.signal
|
||||
markStarted?.()
|
||||
return held
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const reason = 'discovery cancelled'
|
||||
const pending = ctx.skills.list({ signal: controller.signal })
|
||||
const outcome = pending.then(
|
||||
() => 'resolved',
|
||||
(error: unknown) => error instanceof Error && error.message === reason ? 'aborted' : 'other-error',
|
||||
)
|
||||
await started
|
||||
controller.abort(reason)
|
||||
|
||||
const templated = new Context()
|
||||
await templated.plugin(SystemPrompt, { persona: 'base' })
|
||||
await templated.plugin(SkillService)
|
||||
templated.skills.registerProvider(new MemoryProvider([memorySkill('templated-skill', 'Use {{placeholder}} safely', 10)]))
|
||||
const prompt = renderPrompt(await templated.systemPrompt.assemble({ agent: agentForCwd('/tmp') }))
|
||||
expect(prompt).toContain('description: Use { {placeholder} } safely')
|
||||
const settled = await Promise.race([
|
||||
outcome,
|
||||
new Promise<'timeout'>(resolve => setTimeout(() => { resolve('timeout') }, 25)),
|
||||
])
|
||||
release?.()
|
||||
await pending.catch(() => undefined)
|
||||
|
||||
expect(seenSignal).toBe(controller.signal)
|
||||
expect(settled).toBe('aborted')
|
||||
})
|
||||
|
||||
it('does not miss an abort racing listener installation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
const reason = new Error('racing abort')
|
||||
let aborted = false
|
||||
const signal = {
|
||||
get aborted() {
|
||||
return aborted
|
||||
},
|
||||
reason,
|
||||
throwIfAborted() {
|
||||
if (aborted) throw reason
|
||||
},
|
||||
addEventListener(_type: string, listener: () => void) {
|
||||
aborted = true
|
||||
listener()
|
||||
},
|
||||
removeEventListener() {},
|
||||
} as unknown as AbortSignal
|
||||
ctx.skills.registerProvider({
|
||||
name: 'racing-abort',
|
||||
list() {
|
||||
return Promise.reject(new Error('late provider failure'))
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await expect(ctx.skills.list({ signal })).rejects.toBe(reason)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('rejects invalid runtime skill registrations and ignores duplicates', async () => {
|
||||
@@ -8,8 +8,6 @@
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../agent" },
|
||||
{ "path": "../system-prompt" }
|
||||
{ "path": "../../../vendor/schemastery" }
|
||||
]
|
||||
}
|
||||
21
packages/skill/tool-skill/README.md
Normal file
21
packages/skill/tool-skill/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-tool-skill
|
||||
|
||||
The model-facing skill catalog and `skill` tool.
|
||||
|
||||
Requires `ctx.tools` and `ctx.skills` (`inject: ['tools', 'skills']`).
|
||||
|
||||
## Session-prefix catalog
|
||||
|
||||
The plugin contributes one user-role `<system-reminder>` catalog through `agent/session-prefix`. It resolves skills for the calling session's cwd, forwards the prefix abort signal to discovery, and lists only sorted `name` and `description` entries; skill bodies, paths, sources, providers, and `whenToUse` hints remain outside the catalog. The catalog is omitted when no model-invocable skills are available.
|
||||
|
||||
`catalogDescriptionMaxLength` controls normalized, XML-escaped catalog descriptions. Its default is `500` and values must be integers of at least `3`, which reserves room for a truncation ellipsis. The [session-prefix RFC](../../../docs/rfc/implemented/feature/2026-07-07-session-prefix.md) defines the request-only, header-logged lifecycle of this message.
|
||||
|
||||
## Tool: `skill`
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | string (required) | Exact kebab-case skill name from the available skills listing. |
|
||||
|
||||
Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers can resolve the right winning skill. A successful call returns one text tool result with `<skill_content name="...">`, containing `<skill_resources>` followed by `<skill_instructions>`. Resource guidance resolves paths or URLs explicitly referenced by the loaded instructions against `resourceBase`; referenced scripts, references, and assets load only when needed, and the tool does not enumerate a skill directory. Local filesystem skills provide a base directory, while remote or embedded providers can provide a URL or opaque provider-managed guidance. A name that cannot be resolved reports that the skill is unknown or no longer available; invalid names and skills marked `disableModelInvocation: true` retain distinct `isError` results.
|
||||
|
||||
The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context.
|
||||
@@ -28,6 +28,9 @@
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
152
packages/skill/tool-skill/src/index.ts
Normal file
152
packages/skill/tool-skill/src/index.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Session-prefix skill catalog and model-facing `skill` loader tool.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-skill
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { assertNever, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import { isSkillName, type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||||
|
||||
export const name = 'tool-skill'
|
||||
export const inject = ['tools', 'skills']
|
||||
|
||||
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
|
||||
|
||||
/** Model-facing skill catalog configuration. */
|
||||
export interface Config {
|
||||
/** Maximum normalized description length rendered in the session catalog; minimum 3. */
|
||||
catalogDescriptionMaxLength?: number
|
||||
}
|
||||
|
||||
/** Validate and default the model-facing skill catalog configuration. */
|
||||
export const Config: z<Config> = z.object({
|
||||
catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH),
|
||||
})
|
||||
|
||||
/** Register the session-prefix skill catalog and the model-facing skill loader. */
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
|
||||
assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3)
|
||||
|
||||
ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise<Message[]> => {
|
||||
const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal })
|
||||
const rest = await next()
|
||||
if (skills.length === 0) return rest
|
||||
return [renderCatalogMessage(skills, catalogDescriptionMaxLength), ...rest]
|
||||
})
|
||||
|
||||
const skillTool = defineTool({
|
||||
name: 'skill',
|
||||
description: 'Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.',
|
||||
parameters: {
|
||||
name: { type: 'string', required: true, description: 'The exact skill name from the available skills list.' },
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (!isSkillName(args.name)) {
|
||||
throw new Error(`invalid skill name "${args.name}"`)
|
||||
}
|
||||
const skill = await ctx.skills.get(args.name, { cwd: exec.agent?.session.header.cwd, signal: exec.signal })
|
||||
if (!skill) {
|
||||
throw new Error(`skill "${args.name}" is unknown or no longer available`)
|
||||
}
|
||||
if (skill.disableModelInvocation === true) {
|
||||
throw new Error(`skill "${args.name}" is not available for model invocation`)
|
||||
}
|
||||
return [{ type: 'text', text: renderSkillContent(skill) }]
|
||||
},
|
||||
presentCall(args) {
|
||||
return { card: 'generic', title: `Load skill ${args.name}`, kind: 'read', rawInput: args.name }
|
||||
},
|
||||
})
|
||||
ctx.tools.register(skillTool)
|
||||
}
|
||||
|
||||
function renderSkillContent(skill: SkillDefinition): string {
|
||||
const resourceHint = renderResourceHint(skill)
|
||||
return [
|
||||
`<skill_content name="${escapeAttr(skill.name)}">`,
|
||||
'<skill_resources>',
|
||||
...resourceHint,
|
||||
'</skill_resources>',
|
||||
'',
|
||||
'<skill_instructions>',
|
||||
skill.content,
|
||||
'</skill_instructions>',
|
||||
'</skill_content>',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function renderResourceHint(skill: SkillDefinition): string[] {
|
||||
const base = skill.resourceBase
|
||||
if (base === undefined) {
|
||||
return [
|
||||
`Resources for this skill are managed by provider "${escapeText(skill.provider)}".`,
|
||||
'Load referenced resources only as needed.',
|
||||
]
|
||||
}
|
||||
switch (base.kind) {
|
||||
case 'directory':
|
||||
return [
|
||||
`Base directory for this skill: ${escapeText(base.path)}`,
|
||||
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
|
||||
]
|
||||
case 'url':
|
||||
return [
|
||||
`Base URL for this skill: ${escapeText(base.url)}`,
|
||||
'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.',
|
||||
]
|
||||
case 'opaque':
|
||||
return [
|
||||
`Resources for this skill: ${escapeText(base.description)}`,
|
||||
'Load referenced resources only as needed.',
|
||||
]
|
||||
default:
|
||||
return assertNever(base, 'SkillResourceBase.kind')
|
||||
}
|
||||
}
|
||||
|
||||
function renderCatalogMessage(skills: SkillSummary[], descriptionMaxLength: number): Message {
|
||||
const entries = skills.map(skill => `- \`${skill.name}\`: ${catalogDescription(skill.description, descriptionMaxLength)}`)
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'<system-reminder>',
|
||||
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
...entries,
|
||||
'</available_skills>',
|
||||
'',
|
||||
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function catalogDescription(value: string, maxLength: number): string {
|
||||
const normalized = value.replaceAll(/\s+/g, ' ').trim()
|
||||
const truncated = normalized.length <= maxLength
|
||||
? normalized
|
||||
: `${normalized.slice(0, maxLength - 3)}...`
|
||||
return escapeText(truncated)
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
|
||||
if (!Number.isInteger(value) || value < minimum) {
|
||||
throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`)
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<')
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
}
|
||||
275
packages/skill/tool-skill/tests/tool-skill.spec.ts
Normal file
275
packages/skill/tool-skill/tests/tool-skill.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
|
||||
async function tempDir(name: string): Promise<string> {
|
||||
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {
|
||||
const dir = join(root, name)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
|
||||
}
|
||||
|
||||
async function setup(home: string, config: toolSkill.Config = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
await ctx.plugin(toolSkill, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function agentForCwd(cwd: string): never {
|
||||
return { session: { header: { cwd } } } as never
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', agentForCwd(cwd), empty, signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
describe('dsh-tool-skill', () => {
|
||||
it('registers the skill tool schema and removes it on dispose', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const home = await tempDir('tool-schema')
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
ctx.skills.register({ name: 'lifecycle-skill', description: 'Lifecycle', source: 'runtime', content: 'body' })
|
||||
|
||||
const fiber = await ctx.plugin(toolSkill)
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
|
||||
expect(await composePrefix(ctx, '/workspace')).toHaveLength(1)
|
||||
expect(ctx.tools.get('skill')?.presentCall?.({ name: 'project-skill' })).toEqual({
|
||||
card: 'generic',
|
||||
title: 'Load skill project-skill',
|
||||
kind: 'read',
|
||||
rawInput: 'project-skill',
|
||||
})
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toEqual([])
|
||||
expect(await composePrefix(ctx, '/workspace')).toEqual([])
|
||||
|
||||
toolSkill.apply(ctx)
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
|
||||
})
|
||||
|
||||
it('forwards the session-prefix abort signal to skill discovery', async () => {
|
||||
const home = await tempDir('tool-prefix-signal')
|
||||
const ctx = await setup(home)
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.skills.registerProvider({
|
||||
name: 'signal-probe',
|
||||
async list(options) {
|
||||
seenSignal = options.signal
|
||||
return []
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
|
||||
await composePrefix(ctx, '/workspace', controller.signal)
|
||||
|
||||
expect(seenSignal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('contributes a stable name-and-description catalog through the session prefix', async () => {
|
||||
const home = await tempDir('tool-catalog')
|
||||
const ctx = await setup(home, { catalogDescriptionMaxLength: 50 })
|
||||
ctx.skills.register({
|
||||
name: 'z-skill',
|
||||
description: 'Long description '.repeat(5),
|
||||
whenToUse: 'Never render this routing hint.',
|
||||
source: 'secret-source',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'directory', path: '/secret/path' },
|
||||
content: 'Secret body.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'a-skill',
|
||||
description: 'Use {{placeholder}} <safely> & carefully.',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'A body.',
|
||||
})
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
|
||||
...await next(),
|
||||
])
|
||||
|
||||
const prefix = await composePrefix(ctx, '/workspace')
|
||||
|
||||
expect(prefix).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'<system-reminder>',
|
||||
'A skill is a reusable set of task-specific instructions. The following skills are available in this session:',
|
||||
'',
|
||||
'<available_skills>',
|
||||
'- `a-skill`: Use {{placeholder}} <safely> & carefully.',
|
||||
'- `z-skill`: Long description Long description Long descript...',
|
||||
'</available_skills>',
|
||||
'',
|
||||
"If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.",
|
||||
'</system-reminder>',
|
||||
].join('\n'),
|
||||
}],
|
||||
},
|
||||
{ role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
|
||||
])
|
||||
const rendered = JSON.stringify(prefix[0])
|
||||
expect(rendered).not.toContain('whenToUse')
|
||||
expect(rendered).not.toContain('secret-source')
|
||||
expect(rendered).not.toContain('/secret/path')
|
||||
expect(rendered).not.toContain('Secret body')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
|
||||
})
|
||||
|
||||
it('does not contribute a session-prefix message when no skills are available', async () => {
|
||||
const home = await tempDir('tool-empty-catalog')
|
||||
const ctx = await setup(home)
|
||||
|
||||
expect(await composePrefix(ctx, '/workspace')).toEqual([])
|
||||
})
|
||||
|
||||
it('validates the catalog description cap', async () => {
|
||||
const home = await tempDir('tool-invalid-catalog-cap')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
|
||||
|
||||
await expect(ctx.plugin(toolSkill, { catalogDescriptionMaxLength: 2 })).rejects.toThrow('greater than or equal to 3')
|
||||
})
|
||||
|
||||
it('loads a skill for the calling agent cwd', async () => {
|
||||
const home = await tempDir('tool-load')
|
||||
const project = await tempDir('tool-project')
|
||||
await mkdir(join(project, '.git'), { recursive: true })
|
||||
await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.')
|
||||
const ctx = await setup(home)
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('c1'),
|
||||
name: 'skill',
|
||||
arguments: { name: 'project-skill' },
|
||||
agent: { session: { header: { cwd: project } } } as never,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const block = result.content[0]
|
||||
expect(block?.type).toBe('text')
|
||||
if (block?.type !== 'text') throw new Error('expected text skill result')
|
||||
expect(block.text).toBe([
|
||||
'<skill_content name="project-skill">',
|
||||
'<skill_resources>',
|
||||
`Base directory for this skill: ${join(project, '.dsh/skills/project-skill')}`,
|
||||
'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.',
|
||||
'</skill_resources>',
|
||||
'',
|
||||
'<skill_instructions>',
|
||||
'Project instructions.',
|
||||
'</skill_instructions>',
|
||||
'</skill_content>',
|
||||
].join('\n'))
|
||||
expect(block.text).not.toContain('# Skill:')
|
||||
})
|
||||
|
||||
it('renders provider-managed resource hints for non-local skills', async () => {
|
||||
const home = await tempDir('tool-resource-hints')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'opaque-skill',
|
||||
description: 'Opaque skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'opaque', description: 'runtime memory' },
|
||||
content: 'Opaque instructions.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'url-skill',
|
||||
description: 'URL skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'url', url: 'https://skills.example.test/url-skill' },
|
||||
content: 'URL instructions.',
|
||||
})
|
||||
ctx.skills.register({
|
||||
name: 'provider-skill',
|
||||
description: 'Provider skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: 'Provider instructions.',
|
||||
})
|
||||
|
||||
const opaque = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } })
|
||||
const url = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } })
|
||||
const provider = await ctx.tools.execute({ callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } })
|
||||
|
||||
if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') {
|
||||
throw new Error('expected text tool results')
|
||||
}
|
||||
expect(opaque.content[0].text).toContain('<skill_resources>\nResources for this skill: runtime memory\nLoad referenced resources only as needed.\n</skill_resources>')
|
||||
expect(url.content[0].text).toContain('<skill_resources>\nBase URL for this skill: https://skills.example.test/url-skill\nResolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.\n</skill_resources>')
|
||||
expect(provider.content[0].text).toContain('<skill_resources>\nResources for this skill are managed by provider "runtime".\nLoad referenced resources only as needed.\n</skill_resources>')
|
||||
})
|
||||
|
||||
it('fails loud on an unknown resource base kind', async () => {
|
||||
const home = await tempDir('tool-resource-assert-never')
|
||||
const ctx = await setup(home)
|
||||
ctx.skills.register({
|
||||
name: 'rogue-resource-skill',
|
||||
description: 'Rogue resource skill',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
resourceBase: { kind: 'future' } as never,
|
||||
content: 'Rogue instructions.',
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
const block = result.content[0]
|
||||
if (block?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(block.text).toContain('unreachable variant')
|
||||
})
|
||||
|
||||
it('returns isError for unknown, invalid, and model-disabled skills', async () => {
|
||||
const home = await tempDir('tool-errors')
|
||||
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'Hidden skill', 'Hidden instructions.')
|
||||
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
|
||||
const ctx = await setup(home)
|
||||
|
||||
const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
|
||||
const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
|
||||
const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
|
||||
|
||||
expect(unknown.isError).toBe(true)
|
||||
expect(invalid.isError).toBe(true)
|
||||
expect(disabled.isError).toBe(true)
|
||||
const unknownBlock = unknown.content[0]
|
||||
if (unknownBlock?.type !== 'text') throw new Error('expected text tool result')
|
||||
expect(unknownBlock.text).toContain('skill "missing" is unknown or no longer available')
|
||||
})
|
||||
})
|
||||
@@ -8,9 +8,10 @@
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../agent" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../skill" },
|
||||
{ "path": "../tools" }
|
||||
{ "path": "../../core/tools" }
|
||||
]
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export interface Config {
|
||||
toolOrder?: string[]
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Skill registry/local-provider config forwarded to the shared agent-core spine. */
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
skills?: agentCore.SkillConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import * as acpAgent from '../src/index.ts'
|
||||
|
||||
/**
|
||||
@@ -26,9 +27,20 @@ async function mount(config: acpAgent.Config): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(): Promise<NonNullable<acpAgent.Config['skills']>> {
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<acpAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-skills-'))
|
||||
return { local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') } }
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never,
|
||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
@@ -92,8 +104,9 @@ describe('dsh-acp-agent composition', () => {
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig() })
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export interface Config {
|
||||
persistenceRoot?: string
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
welcome?: string
|
||||
/** Skill registry/local-provider config forwarded to the shared agent-core spine. */
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/**
|
||||
* If set, the `main` agent RESUMES this persisted session id instead of
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as stdioAgent from '../src/index.ts'
|
||||
|
||||
@@ -33,9 +34,20 @@ async function mount(config: stdioAgent.Config): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(): Promise<NonNullable<stdioAgent.Config['skills']>> {
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<stdioAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-'))
|
||||
return { local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') } }
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never,
|
||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
@@ -117,8 +129,9 @@ describe('dsh-stdio-agent app', () => {
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig() })
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user