feat(skill): move catalogs into session prefixes

This commit is contained in:
Yichen Jiang
2026-07-10 14:19:06 +08:00
parent b9bf67d0a7
commit 6292d52236
54 changed files with 1019 additions and 691 deletions

View File

@@ -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 |

View File

@@ -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',

View File

@@ -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.

View File

@@ -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

View File

@@ -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 ?? [] })
}

View File

@@ -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()
})

View File

@@ -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"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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')
}
}

View File

@@ -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
View 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).

View File

@@ -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

View 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.

View File

@@ -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"
}
}

View File

@@ -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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
}
function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
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)
}

View File

@@ -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 () => {

View File

@@ -8,8 +8,6 @@
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../agent" },
{ "path": "../system-prompt" }
{ "path": "../../../vendor/schemastery" }
]
}

View 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.

View File

@@ -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:^",

View 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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
}
function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}

View 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}} &lt;safely&gt; &amp; 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')
})
})

View File

@@ -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" }
]
}

View File

@@ -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
}

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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()
})